@iislee/opencodex 2.31.0 → 2.35.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 (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. package/gui/dist/assets/index-DMiI18Kv.css +0 -1
@@ -22,12 +22,15 @@ import {
22
22
  durableReplayDestinationIdentity,
23
23
  durableReplayCredentialIdentity,
24
24
  reasoningReplayKeyCredentialIdentity,
25
+ reasoningReplayOpaqueBlobRejectionMemoized,
25
26
  reasoningReplayOAuthCredentialIdentity,
26
27
  reasoningReplayServingIdentityChanged,
28
+ rememberReasoningReplayOpaqueBlobRejection,
27
29
  } from "../../responses/reasoning-replay-cache";
28
30
  import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay";
29
31
  import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
30
32
  import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
33
+ import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema";
31
34
  import {
32
35
  copyPreviousResponseReplayProvenance,
33
36
  expandPreviousResponseInput,
@@ -37,6 +40,10 @@ import {
37
40
  previousResponseScopeMismatch,
38
41
  rememberResponseState,
39
42
  } from "../../responses/state";
43
+ import {
44
+ bindTurnTerminationScope,
45
+ rememberDeliveredFinalAnswer,
46
+ } from "../../responses/turn-termination";
40
47
  import {
41
48
  isValidProviderContinuationOwner,
42
49
  mergeProviderContinuationPayload,
@@ -69,6 +76,13 @@ import {
69
76
  targetKey,
70
77
  } from "../../combos";
71
78
  import { isInjectionDebugEnabled } from "../../lib/debug-settings";
79
+ import {
80
+ CYBER_POLICY_ERROR_CODE,
81
+ CYBER_POLICY_FALLBACK_MESSAGE,
82
+ adapterFailureFromMessage,
83
+ isCyberPolicyCode,
84
+ isCyberPolicyMessage,
85
+ } from "../../lib/errors";
72
86
  import { injectionDebugLog } from "../../lib/injection-debug-log";
73
87
  import { resolveClientRetryAfter } from "../../lib/retry-after";
74
88
  import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit";
@@ -105,6 +119,14 @@ import {
105
119
  resolveAnthropicAccountForSession,
106
120
  rotateAnthropicAccountOn429,
107
121
  } from "../../oauth/anthropic-routing";
122
+ import { stampOAuthAccountLabel } from "../../providers/label";
123
+ import {
124
+ failoverAccountSnapshot,
125
+ GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
126
+ isGenericFailoverProvider,
127
+ isGenericOAuthFailoverEnabled,
128
+ rotateGenericOAuthAccountOn429,
129
+ } from "../../oauth/generic-account-failover";
108
130
  import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
109
131
  import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
110
132
  import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
@@ -113,16 +135,12 @@ import {
113
135
  applyCodexAuthContextToProvider,
114
136
  codexPoolAffinityKey,
115
137
  CodexAccountCooldownError,
116
- codexMainProfileDrainingResponse,
117
- cooldownErrorResponse,
118
138
  CodexAuthContextError,
119
- CodexDirectAuthenticationError,
120
139
  CodexMainProfileDrainingError,
121
140
  CodexPoolAuthenticationError,
122
141
  CodexThreadAffinityExpiredError,
123
142
  headersForCodexAuthContext,
124
143
  materializeCodexUpstreamAuth,
125
- CodexMainSubstitutionUnavailableError,
126
144
  isCodexAuthContextUsable,
127
145
  resolveCodexAuthContext,
128
146
  codexProbeLeaseId,
@@ -137,6 +155,7 @@ import {
137
155
  resolveCodexModelEntitlements,
138
156
  } from "../../codex/model-entitlements";
139
157
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
158
+ import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account";
140
159
  import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug";
141
160
  import {
142
161
  computeQuotaCooldown,
@@ -185,7 +204,7 @@ import {
185
204
  providerModelResponsesUpstreamStreaming,
186
205
  type InboundWire,
187
206
  } from "../../providers/registry";
188
- import type { AdapterRequest } from "../../adapters/base";
207
+ import type { AdapterRequest, ProviderAdapter } from "../../adapters/base";
189
208
  import {
190
209
  hasKeyPoolFailover,
191
210
  rateLimitRetryDelayMs,
@@ -193,7 +212,7 @@ import {
193
212
  rotateProviderTransportOn429,
194
213
  } from "../../providers/key-failover";
195
214
  import { shouldAttemptImageTierRetry } from "../image-retry";
196
- import { resolveProviderTransport } from "../../providers/xai-transport";
215
+ import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport";
197
216
  import type { WsData } from "../ws-bridge";
198
217
  import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle";
199
218
  import { redactSecretString, sanitizeLogMetadataString } from "../../lib/redact";
@@ -205,6 +224,10 @@ import {
205
224
  applySubagentModelFallback,
206
225
  maybePrimeSubagentQuota,
207
226
  recordSubagentQuotaFailureForThreadSpawn,
227
+ resolveSubagentFallbackChain,
228
+ subagentFallbackNeedsModelEntitlements,
229
+ type SubagentModelEligibleAccountIds,
230
+ type SubagentPoolAccountPreview,
208
231
  } from "../../codex/subagent-model-fallback";
209
232
  import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
210
233
  import {
@@ -225,6 +248,7 @@ import {
225
248
  import {
226
249
  conversationIdFromResponsesRequest,
227
250
  normalizeLogConversationId,
251
+ reasoningReplayConversationIdFromResponsesRequest,
228
252
  sessionIdHeaderFromRequest,
229
253
  } from "../request-log-conversation";
230
254
  import type { AttemptRecoveryKind } from "../../usage/log";
@@ -271,6 +295,7 @@ import { createResponsesModelPayloadRewrite, rewriteResponsesModelJson } from ".
271
295
  import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
272
296
 
273
297
  import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
298
+ import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
274
299
  import { hasUnreadableEncryptedAgentTask, looksLikeBackendCiphertext, sanitizeEncryptedContentInPlace } from "./encrypted-payload";
275
300
  import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers";
276
301
  import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
@@ -295,7 +320,7 @@ import {
295
320
  payloadRewriteAsBlockRewrite,
296
321
  relaySseWithBlockRewrite,
297
322
  } from "../sse-payload-rewrite";
298
- import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat";
323
+ import { restoreRoutedCustomCalls, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat";
299
324
  import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair";
300
325
  import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat";
301
326
  import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair";
@@ -306,19 +331,31 @@ import {
306
331
  type RoutedNamespaceToolAliases,
307
332
  } from "../../responses/namespace-tool-compat";
308
333
  import {
334
+ collectDeclaredNamelessClientCallTypes,
309
335
  collectDeclaredWireToolNames,
336
+ collectProviderExecutedCallTypes,
310
337
  createUndeclaredToolCallGuardBlockRewrite,
338
+ currentTurnWireToolCatalogBody,
339
+ hasExplicitWireToolCatalog,
311
340
  undeclaredToolCallMessage,
312
341
  undeclaredToolCallName,
313
342
  undeclaredToolCallNameInResponse,
343
+ type ProviderExecutedCallType,
314
344
  } from "../responses-undeclared-tool-guard";
315
345
  import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
316
346
  import { responsesJsonToSseStream } from "../responses-json-events";
317
347
  import { guardTerminalEventStream } from "./terminal-guard";
318
348
  import {
319
349
  emptyCompletionRetryEnabled,
350
+ observeEmptyCompletion,
320
351
  guardEmptyCompletionEventStream,
321
352
  } from "./empty-completion-guard";
353
+ import { preflightComboStreamResponse } from "./combo-stream-preflight";
354
+
355
+ // runTurn adapters own an event queue and perform their combo preflight before
356
+ // bridging. A second byte-stream reader would reinterpret that transport's
357
+ // already-committed event boundary and can replay custom adapter work.
358
+ const runTurnAdapterSseResponses = new WeakSet<Response>();
322
359
 
323
360
  /**
324
361
  * Adapters whose continuation state must survive Codex's store:false requests.
@@ -515,6 +552,9 @@ function bindRouteReasoningReplayScope(args: {
515
552
  if (reasoningReplayServingIdentityChanged(parsed._reasoningReplayScope)) {
516
553
  parsed._stripReasoningEncryptedContent = true;
517
554
  }
555
+ if (reasoningReplayOpaqueBlobRejectionMemoized(parsed._reasoningReplayScope)) {
556
+ parsed._stripReasoningEncryptedContent = true;
557
+ }
518
558
  bindProviderContinuationForRoute(parsed, continuationOwner);
519
559
  }
520
560
 
@@ -628,6 +668,68 @@ async function opaqueBlobRejectionBodyForRecovery(
628
668
  }
629
669
  }
630
670
 
671
+ /**
672
+ * Materialize an upstream error body only when the bounded reader observed a complete,
673
+ * display-safe payload. Partial timeout and over-limit prefixes are attacker-controlled,
674
+ * so callers keep their existing status-only fallback instead.
675
+ */
676
+ export async function readDisplaySafeErrorText(
677
+ response: Response,
678
+ signal: AbortSignal,
679
+ fallback: string,
680
+ ): Promise<string> {
681
+ try {
682
+ const body = await readBoundedResponseBody(response, { signal });
683
+ return body.displaySafe ? body.text : fallback;
684
+ } catch {
685
+ // Preserve the former Response.text().catch(fallback) contract. Request-abort
686
+ // classification remains owned by the surrounding response pipeline.
687
+ return fallback;
688
+ }
689
+ }
690
+
691
+ interface NormalizedUpstreamErrorText {
692
+ safeText: string;
693
+ message?: string;
694
+ type?: string;
695
+ code?: string;
696
+ cyberPolicy: boolean;
697
+ }
698
+
699
+ /**
700
+ * Extract the structured provider error envelope without making `error.type` authoritative.
701
+ * Policy identity comes from the dedicated code (or the legacy message fallback); a credible
702
+ * upstream type is only carried through so callers do not erase provider diagnostics.
703
+ */
704
+ function normalizeUpstreamErrorText(text: string, fallback: string): NormalizedUpstreamErrorText {
705
+ const safeText = redactSecretString(text).slice(0, 500).trim() || fallback;
706
+ let message: string | undefined;
707
+ let type: string | undefined;
708
+ let code: string | undefined;
709
+ try {
710
+ const parsed = JSON.parse(text) as Record<string, unknown>;
711
+ const response = parsed.response && typeof parsed.response === "object" && !Array.isArray(parsed.response)
712
+ ? parsed.response as Record<string, unknown>
713
+ : undefined;
714
+ const candidates = [parsed.error, response?.error, response?.last_error, parsed.last_error, parsed];
715
+ const source = candidates.find((candidate): candidate is Record<string, unknown> => {
716
+ if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) return false;
717
+ const record = candidate as Record<string, unknown>;
718
+ return [record.message, record.type, record.code].some(value => typeof value === "string");
719
+ });
720
+ if (!source) return { safeText, cyberPolicy: isCyberPolicyMessage(safeText) };
721
+ if (typeof source.message === "string" && source.message.trim()) {
722
+ message = redactSecretString(source.message.trim()).slice(0, 500);
723
+ }
724
+ if (typeof source.type === "string" && source.type.trim()) type = source.type.trim();
725
+ if (typeof source.code === "string" && source.code.trim()) code = source.code.trim();
726
+ } catch {
727
+ /* non-JSON upstream body — retain the bounded display-safe text */
728
+ }
729
+ const cyberPolicy = isCyberPolicyCode(code) || isCyberPolicyMessage(message ?? safeText);
730
+ return { safeText, message, type, code, cyberPolicy };
731
+ }
732
+
631
733
  function prepareOpaqueBlobRecovery(parsed: OcxParsedRequest): void {
632
734
  parsed._stripReasoningEncryptedContent = true;
633
735
  }
@@ -668,9 +770,20 @@ async function attemptOpaqueBlobRecovery(
668
770
  }
669
771
 
670
772
  args.guard.attempted = true;
773
+ const rejectedScope = args.parsed._reasoningReplayScope
774
+ ? {
775
+ clientThreadId: args.parsed._reasoningReplayScope.clientThreadId,
776
+ ...(args.parsed._reasoningReplayScope.current
777
+ ? { current: { ...args.parsed._reasoningReplayScope.current } }
778
+ : {}),
779
+ }
780
+ : undefined;
671
781
  prepareOpaqueBlobRecovery(args.parsed);
672
782
  try { void args.response.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
673
783
  const result = await rebuild("opaque-blob-rejection");
784
+ if (!("failed" in result) && result.ok) {
785
+ rememberReasoningReplayOpaqueBlobRejection(rejectedScope);
786
+ }
674
787
  return "failed" in result
675
788
  ? { kind: "failed", response: result.failed }
676
789
  : { kind: "recovered", response: result };
@@ -828,11 +941,15 @@ export function codexAccountGatedCanonicalWireModel(modelId: string): string | u
828
941
  return undefined;
829
942
  }
830
943
 
831
- function applyCodexAccountGatedWireNormalization(parsed: OcxParsedRequest, route: RouteResult): void {
944
+ function applyCodexAccountGatedWireNormalization(parsed: OcxParsedRequest, route: RouteResult, logCtx?: RequestLogContext): void {
832
945
  if (!isCanonicalOpenAiForwardProvider(route.provider)) return;
833
946
  const wireModel = codexAccountGatedCanonicalWireModel(route.modelId);
834
947
  if (!wireModel) return;
835
948
 
949
+ if (logCtx) {
950
+ logCtx.preserveResolvedModelFromRoute = true;
951
+ delete logCtx.resolvedModel;
952
+ }
836
953
  parsed.modelId = wireModel;
837
954
  if (!parsed._rawBody || typeof parsed._rawBody !== "object") return;
838
955
  const raw = parsed._rawBody as Record<string, unknown>;
@@ -1184,6 +1301,8 @@ export interface HandleResponsesOptions {
1184
1301
  /** One-shot TTFT callback: first non-empty model output observed (WP4). */
1185
1302
  onFirstOutput?: () => void;
1186
1303
  onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void;
1304
+ /** Internal deterministic seam for account-gated native fallback tests. */
1305
+ resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements;
1187
1306
  recordTerminalOutcomes?: boolean;
1188
1307
  setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void;
1189
1308
  onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
@@ -1267,27 +1386,30 @@ export async function consumeComboFailure(
1267
1386
  let classificationText = fallback;
1268
1387
  let usage: OcxUsage | undefined;
1269
1388
  let upstreamCode: string | undefined;
1389
+ let upstreamMessage: string | undefined;
1390
+ let upstreamType: string | undefined;
1270
1391
  try {
1271
1392
  const body = await readBoundedResponseBody(response, { signal });
1272
1393
  usage = usageFromComboFailureText(body.text);
1273
1394
  if (body.displaySafe) {
1274
- const safeText = redactSecretString(body.text).slice(0, 500);
1275
- if (safeText) classificationText = safeText;
1276
- try {
1277
- const parsed = JSON.parse(body.text) as { error?: { code?: unknown } | string };
1278
- const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.code : undefined;
1279
- if (typeof nested === "string" && nested.length > 0) upstreamCode = nested;
1280
- } catch {
1281
- /* non-JSON upstream body — message-only classification */
1282
- }
1395
+ const normalized = normalizeUpstreamErrorText(body.text, fallback);
1396
+ classificationText = normalized.safeText;
1397
+ upstreamCode = normalized.code;
1398
+ upstreamMessage = normalized.message;
1399
+ upstreamType = normalized.type;
1283
1400
  }
1284
1401
  } catch (error) {
1285
1402
  if (signal?.aborted) throw error;
1286
1403
  classificationText = fallback;
1287
1404
  }
1288
- const message = classificationText === fallback
1289
- ? fallback
1290
- : `${fallback}: ${classificationText}`;
1405
+ const cyberFailure = isCyberPolicyCode(upstreamCode) || isCyberPolicyMessage(classificationText);
1406
+ const normalizedUpstreamCode = cyberFailure ? CYBER_POLICY_ERROR_CODE : upstreamCode;
1407
+ const message = cyberFailure
1408
+ ? upstreamMessage
1409
+ ?? (isCyberPolicyCode(upstreamCode) ? CYBER_POLICY_FALLBACK_MESSAGE : classificationText)
1410
+ : classificationText === fallback
1411
+ ? fallback
1412
+ : `${fallback}: ${classificationText}`;
1291
1413
  const upstreamRetryAfter = response.headers.get("retry-after");
1292
1414
  // Client response may get the synthetic "2" fallback; cooldown metadata must not —
1293
1415
  // otherwise coolComboTarget treats it as a 2s cooldown instead of the 60s default.
@@ -1305,13 +1427,18 @@ export async function consumeComboFailure(
1305
1427
  includeDefault: false,
1306
1428
  });
1307
1429
  return {
1308
- response: formatErrorResponse(response.status, "upstream_error", message, {
1309
- ...(upstreamCode !== undefined ? { code: upstreamCode } : {}),
1310
- ...(clientRetryAfter !== undefined ? { retryAfter: clientRetryAfter } : {}),
1311
- }),
1430
+ response: formatErrorResponse(
1431
+ response.status,
1432
+ cyberFailure ? (upstreamType ?? CYBER_POLICY_ERROR_CODE) : "upstream_error",
1433
+ message,
1434
+ {
1435
+ ...(normalizedUpstreamCode !== undefined ? { code: normalizedUpstreamCode } : {}),
1436
+ ...(clientRetryAfter !== undefined ? { retryAfter: clientRetryAfter } : {}),
1437
+ },
1438
+ ),
1312
1439
  classificationText,
1313
- ...(upstreamCode !== undefined ? { upstreamCode } : {}),
1314
- ...(cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}),
1440
+ ...(normalizedUpstreamCode !== undefined ? { upstreamCode: normalizedUpstreamCode } : {}),
1441
+ ...(!cyberFailure && cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}),
1315
1442
  ...(usage ? { usage } : {}),
1316
1443
  };
1317
1444
  }
@@ -1475,6 +1602,7 @@ async function resolveResponsesCodexAuth(
1475
1602
  modelId: route.modelId,
1476
1603
  substituteMainCredentialForDirect: substituteMainCredential,
1477
1604
  beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
1605
+ resolveCodexModelEntitlements: options.resolveCodexModelEntitlements,
1478
1606
  });
1479
1607
  options.onCodexAuthContextResolved?.(authCtx);
1480
1608
  } else {
@@ -1495,48 +1623,43 @@ async function resolveResponsesCodexAuth(
1495
1623
  substituteMainCredential,
1496
1624
  };
1497
1625
  } catch (err) {
1498
- if (err instanceof CodexAccountCooldownError) {
1499
- return { ok: false, response: cooldownErrorResponse(err, Date.now(), route.codexAccountNamespace) };
1500
- }
1501
- if (err instanceof CodexMainProfileDrainingError) {
1502
- return { ok: false, response: codexMainProfileDrainingResponse() };
1503
- }
1504
- if (err instanceof CodexThreadAffinityExpiredError) {
1505
- return {
1506
- ok: false,
1507
- response: formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session"),
1508
- };
1509
- }
1510
1626
  if (err instanceof CodexAuthContextError) {
1511
1627
  const safeAccountLabel = route.codexAccountNamespace
1512
1628
  ? `${route.providerName}-${route.codexAccountNamespace}`
1513
1629
  : formatCodexProviderForLog(route.providerName, err.accountId, config);
1514
1630
  console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`);
1515
- return {
1516
- ok: false,
1517
- response: formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"),
1518
- };
1519
- }
1520
- if (err instanceof CodexPoolAuthenticationError) {
1521
- return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
1522
- }
1523
- if (err instanceof CodexDirectAuthenticationError) {
1524
- return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
1525
1631
  }
1526
1632
  if (err instanceof ForwardAdmissionCredentialError) {
1527
1633
  return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
1528
1634
  }
1529
- if (err instanceof CodexMainSubstitutionUnavailableError) {
1530
- // Fail BEFORE any upstream I/O. The alternative is forwarding the admission secret.
1531
- return {
1532
- ok: false,
1533
- response: formatErrorResponse(401, "authentication_error", "No usable Codex main credential to serve this request"),
1534
- };
1535
- }
1635
+ const response = mapCodexAuthContextErrorToResponse(err, {
1636
+ accountSelector: route.codexAccountNamespace,
1637
+ now: Date.now(),
1638
+ });
1639
+ if (response) return { ok: false, response };
1536
1640
  throw err;
1537
1641
  }
1538
1642
  }
1539
1643
 
1644
+ async function resolveSubagentFallbackModelEligibility(args: {
1645
+ config: OcxConfig;
1646
+ fallbackChain: readonly string[] | null;
1647
+ nativeMainReadsForbidden: boolean;
1648
+ resolver: typeof resolveCodexModelEntitlements;
1649
+ }): Promise<SubagentModelEligibleAccountIds | undefined> {
1650
+ if (!subagentFallbackNeedsModelEntitlements(args.fallbackChain, args.config)) return undefined;
1651
+ const excludeAccountIds = args.nativeMainReadsForbidden
1652
+ ? new Set([MAIN_CODEX_ACCOUNT_ID])
1653
+ : undefined;
1654
+ const snapshot = await args.resolver(args.config, { excludeAccountIds });
1655
+ return (modelId) => {
1656
+ const entitledAccountIds = entitledCodexAccountIdsForModel(snapshot, modelId);
1657
+ return entitledAccountIds
1658
+ ? new Set([...entitledAccountIds].filter(accountId => !excludeAccountIds?.has(accountId)))
1659
+ : undefined;
1660
+ };
1661
+ }
1662
+
1540
1663
  /**
1541
1664
  * Apply every route-dependent request mutation against the final selected route.
1542
1665
  * Must run only after subagent fallback has settled the model/provider.
@@ -1581,6 +1704,7 @@ async function applyFinalRouteRequestNormalization(args: {
1581
1704
  logCtx.provider = route.providerName;
1582
1705
  logCtx.providerAdapter = route.provider.adapter;
1583
1706
  logCtx.routeDecision = route.routeDecision;
1707
+ if (route.routeReason === "model-alias" || route.modelId !== responseModelId && responseModelId.includes("/")) logCtx.requestedAlias = responseModelId;
1584
1708
 
1585
1709
  if (responsesUpstreamStreaming === false && route.provider.adapter === "openai-responses") {
1586
1710
  parsed.stream = false;
@@ -1623,7 +1747,15 @@ async function applyFinalRouteRequestNormalization(args: {
1623
1747
  );
1624
1748
  const modelServiceTierSupport = serviceTierSupportFromPolicy(fastPolicy);
1625
1749
  const callerTier = parsed.options.serviceTier;
1626
- parsed.options.tierObservation = tierObservationContext(fastPolicy, config.fastMode, callerTier);
1750
+ // The ChatGPT-internal Codex backend echoes `service_tier: "default"` even on turns it
1751
+ // scheduled as priority, so its echo cannot confirm OR deny Fast. Believing it reported every
1752
+ // Fast request as `response-declined` (#2558). The public API's echo stays authoritative.
1753
+ parsed.options.tierObservation = tierObservationContext(
1754
+ fastPolicy,
1755
+ config.fastMode,
1756
+ callerTier,
1757
+ isCanonicalOpenAiForwardProvider(route.provider) ? false : undefined,
1758
+ );
1627
1759
  parsed.options.tierDecision = decideTier(fastPolicy, config.fastMode, callerTier);
1628
1760
  parsed.options.serviceTier = tierValueAfterDecision(parsed.options.tierDecision, callerTier);
1629
1761
  if (fastPolicy.capability === true && fastPolicy.fastWire === null) {
@@ -1900,6 +2032,31 @@ export async function handleComboResponses(
1900
2032
  return clientCancelledResponse();
1901
2033
  }
1902
2034
 
2035
+ if (response.ok && !runTurnAdapterSseResponses.has(response)) {
2036
+ const nativePassthrough = isNativePassthroughSseResponse(response);
2037
+ const eagerRelay = isEagerRelaySseResponse(response);
2038
+ let preflight;
2039
+ try {
2040
+ preflight = await preflightComboStreamResponse(response, childLog);
2041
+ } catch (error) {
2042
+ callbackGate.discard();
2043
+ if (options.abortSignal?.aborted) {
2044
+ retainCancelledAttempt();
2045
+ return clientCancelledResponse();
2046
+ }
2047
+ throw error;
2048
+ }
2049
+ if (preflight.kind === "failed") {
2050
+ callbackGate.discard();
2051
+ terminalRecorder?.("failed", preflight.response.status);
2052
+ response = preflight.response;
2053
+ } else {
2054
+ response = preflight.response;
2055
+ if (nativePassthrough) markNativePassthroughSseResponse(response);
2056
+ if (eagerRelay) markEagerRelaySseResponse(response);
2057
+ }
2058
+ }
2059
+
1903
2060
  if (response.ok) {
1904
2061
  sealRequestAttemptIdentity(
1905
2062
  attempt,
@@ -1955,7 +2112,7 @@ export async function handleComboResponses(
1955
2112
  );
1956
2113
  finishRequestAttempt(
1957
2114
  attempt,
1958
- response.status,
2115
+ failure.response.status,
1959
2116
  Date.now() - started,
1960
2117
  failure.usage,
1961
2118
  );
@@ -1969,7 +2126,7 @@ export async function handleComboResponses(
1969
2126
  return lastFailure;
1970
2127
  }
1971
2128
  console.warn(
1972
- `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${response.status} after ${Date.now() - started}ms`,
2129
+ `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${Date.now() - started}ms`,
1973
2130
  );
1974
2131
  const nextPick = advanceComboAfterFailure(config, pick, {
1975
2132
  retryAfter: failure.retryAfter,
@@ -2138,6 +2295,7 @@ async function handleResponsesInner(
2138
2295
  (body as { input?: unknown } | undefined)?.input,
2139
2296
  );
2140
2297
  const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
2298
+ const cursorClientThreadId = codexPoolAffinityKey(req.headers);
2141
2299
  const originalBody = body;
2142
2300
  if (options.comboReplaySnapshot) {
2143
2301
  copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body);
@@ -2185,7 +2343,6 @@ async function handleResponsesInner(
2185
2343
  if (providerContinuationCandidate) parsed._providerContinuationCandidate = providerContinuationCandidate;
2186
2344
  if (inboundClientThreadId) {
2187
2345
  parsed._clientThreadId = inboundClientThreadId;
2188
- parsed._reasoningReplayScope = { clientThreadId: inboundClientThreadId };
2189
2346
  } else if (
2190
2347
  options.inboundWire === "anthropic"
2191
2348
  && options.promptCacheKeyIsSharedCohort !== true
@@ -2208,6 +2365,7 @@ async function handleResponsesInner(
2208
2365
  parsed._reasoningReplayScope = { clientThreadId: normalizedCacheKey };
2209
2366
  }
2210
2367
  }
2368
+ if (cursorClientThreadId) parsed._cursorClientThreadId = cursorClientThreadId;
2211
2369
  } catch (err) {
2212
2370
  if (isTranslatorBudgetExceededError(err)) {
2213
2371
  return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", {
@@ -2221,15 +2379,35 @@ async function handleResponsesInner(
2221
2379
  ...(force ? { force: true } : {}),
2222
2380
  ...(parsed._clientThreadId ? { clientThreadId: parsed._clientThreadId } : {}),
2223
2381
  });
2224
- // Prefer a pre-populated id (routed Claude) over Responses headers that may be
2225
- // absent or synthetically injected (session_id from prompt_cache_key).
2226
- if (!logCtx.conversationId) {
2227
- logCtx.conversationId = conversationIdFromResponsesRequest({
2382
+ const resolvedConversationId = conversationIdFromResponsesRequest({
2383
+ clientThreadId: parsed._clientThreadId,
2384
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
2385
+ threadIdHeader: req.headers.get("thread-id"),
2386
+ cursorConversationId: parsed._cursorConversationId,
2387
+ });
2388
+ bindTurnTerminationScope(parsed, resolvedConversationId);
2389
+ const rememberKiroDeliveredFinalAnswer = (adapterName: string, response: unknown): void => {
2390
+ if (adapterName === "kiro") rememberDeliveredFinalAnswer(parsed, response);
2391
+ };
2392
+ // _clientThreadId remains the routing/continuation identity supplied by Codex. Replay state uses
2393
+ // a dedicated raw conversation namespace so mixed headers that carry the same identity still
2394
+ // match, and a shared/synthetic session_id cannot coalesce distinct thread/Cursor conversations.
2395
+ // Keep an Anthropic prompt_cache_key scope already bound above (#1735/#1926).
2396
+ if (!parsed._reasoningReplayScope) {
2397
+ const reasoningReplayConversationId = reasoningReplayConversationIdFromResponsesRequest({
2228
2398
  clientThreadId: parsed._clientThreadId,
2229
- sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
2230
2399
  threadIdHeader: req.headers.get("thread-id"),
2231
2400
  cursorConversationId: parsed._cursorConversationId,
2401
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
2232
2402
  });
2403
+ if (reasoningReplayConversationId) {
2404
+ parsed._reasoningReplayScope = { clientThreadId: reasoningReplayConversationId };
2405
+ }
2406
+ }
2407
+ // Prefer a pre-populated id (routed Claude) over Responses headers that may be
2408
+ // absent or synthetically injected (session_id from prompt_cache_key).
2409
+ if (!logCtx.conversationId) {
2410
+ logCtx.conversationId = resolvedConversationId;
2233
2411
  }
2234
2412
  logCtx.requestedModel = parsed.modelId;
2235
2413
  logCtx.requestedEffort = parsed.options.reasoning;
@@ -2293,7 +2471,12 @@ async function handleResponsesInner(
2293
2471
  // also fail closed without polling quota upstream. Cached fallback state can still select a
2294
2472
  // provider with native continuation support below.
2295
2473
  const threadSpawn = isThreadSpawnRequest(req.headers);
2296
- const previewSelectionAdmission = threadSpawn && route.codexAccountId === undefined
2474
+ const initialSubagentFallbackChain = threadSpawn && !options.comboAttempt
2475
+ ? resolveSubagentFallbackChain(parsed, config)
2476
+ : null;
2477
+ const previewSelectionAdmission = threadSpawn
2478
+ && !options.comboAttempt
2479
+ && (route.codexAccountId === undefined || initialSubagentFallbackChain !== null)
2297
2480
  ? codexAccountSelectionForTurn(options.turnAdmissionLease)?.()
2298
2481
  : undefined;
2299
2482
  const nativeMainRecoveryBlocked = isNativeMainTrafficBlocked();
@@ -2305,7 +2488,8 @@ async function handleResponsesInner(
2305
2488
  };
2306
2489
  let selectedForwardHeaders = req.headers;
2307
2490
  let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
2308
- let subagentFallbackPreviewAccountId: string | null | undefined;
2491
+ let subagentFallbackAccountPreview: SubagentPoolAccountPreview | undefined;
2492
+ let subagentFallbackModelEligibleAccountIdsForModel: SubagentModelEligibleAccountIds | undefined;
2309
2493
  let subagentQuotaFailureModel = parsed.modelId;
2310
2494
  const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null;
2311
2495
  const poolAffinityKey = codexPoolAffinityKey(req.headers) ?? null;
@@ -2323,28 +2507,47 @@ async function handleResponsesInner(
2323
2507
  // normalization (virtual models, effort caps, service tier, wire protocol).
2324
2508
  // Preview the preferred Codex account without acquiring a probe lease or refreshing
2325
2509
  // tokens — auth is resolved only after the final route is selected.
2326
- if (threadSpawn && !options.comboAttempt && route.codexAccountId === undefined) {
2510
+ if (
2511
+ threadSpawn
2512
+ && !options.comboAttempt
2513
+ && (route.codexAccountId === undefined || initialSubagentFallbackChain !== null)
2514
+ ) {
2327
2515
  // The final resolveCodexAuthContext binds under codexQuotaScopeForModel(route.modelId),
2328
2516
  // so the preview must read the same scope slot — an undefined scope would map to the
2329
2517
  // "legacy" affinity bucket and never find a binding made under "shared" or a native
2330
2518
  // model scope, making the preview diverge from the account that actually authenticates.
2331
- const previewAccountId = previewCodexAccountForRequest(
2519
+ const fallbackChain = initialSubagentFallbackChain;
2520
+ subagentFallbackModelEligibleAccountIdsForModel = await resolveSubagentFallbackModelEligibility({
2521
+ config,
2522
+ fallbackChain,
2523
+ nativeMainReadsForbidden,
2524
+ resolver: options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements,
2525
+ });
2526
+ const fallbackNow = Date.now();
2527
+ subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest(
2332
2528
  poolAffinityKey,
2333
2529
  config,
2334
- Date.now(),
2335
- codexQuotaScopeForModel(route.modelId),
2336
- previewSelectionOptions,
2530
+ previewNow,
2531
+ codexQuotaScopeForModel(modelId),
2532
+ { ...previewSelectionOptions, modelEligibleAccountIds },
2533
+ );
2534
+ const previewAccountId = route.codexAccountId ?? subagentFallbackAccountPreview(
2535
+ route.modelId,
2536
+ fallbackNow,
2537
+ subagentFallbackModelEligibleAccountIdsForModel?.(route.modelId),
2337
2538
  );
2338
- subagentFallbackPreviewAccountId = previewAccountId;
2339
2539
  subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null;
2340
2540
  const fallback = applySubagentModelFallback(
2341
2541
  parsed,
2342
2542
  req.headers,
2343
2543
  config,
2344
2544
  previewAccountId,
2345
- Date.now(),
2545
+ fallbackNow,
2346
2546
  unreadableEncryptedAgentTask,
2347
2547
  previewSelectionOptions,
2548
+ subagentFallbackAccountPreview,
2549
+ subagentFallbackModelEligibleAccountIdsForModel,
2550
+ fallbackChain,
2348
2551
  );
2349
2552
  if (fallback) {
2350
2553
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
@@ -2410,6 +2613,7 @@ async function handleResponsesInner(
2410
2613
  "_providerContinuationOwner",
2411
2614
  "_cursorConversationId",
2412
2615
  "_clientThreadId",
2616
+ "_cursorClientThreadId",
2413
2617
  "_reasoningReplayScope",
2414
2618
  "_cursorIsolateConversation",
2415
2619
  ];
@@ -2427,15 +2631,47 @@ async function handleResponsesInner(
2427
2631
  // The ciphertext-only pass intentionally excludes routed candidates. Once recovery
2428
2632
  // makes the assignment readable, run selection again with the full configured chain
2429
2633
  // and keep the route in sync with any newly selected fallback.
2430
- const fallback = applySubagentModelFallback(
2431
- parsed,
2432
- req.headers,
2433
- config,
2434
- subagentFallbackPreviewAccountId,
2435
- Date.now(),
2436
- false,
2437
- previewSelectionOptions,
2438
- );
2634
+ const recoverySelectionAdmission = codexAccountSelectionForTurn(options.turnAdmissionLease)?.();
2635
+ const fallback = (() => {
2636
+ try {
2637
+ const recoveryNativeMainBlocked = isNativeMainTrafficBlocked();
2638
+ const recoverySelectionOptions = {
2639
+ nativeMainSelectionOnly: !recoveryNativeMainBlocked
2640
+ && recoverySelectionAdmission?.mainProfileDraining === true,
2641
+ };
2642
+ const recoveryNow = Date.now();
2643
+ // Carry the entitlement filter through recovery too (#2509/#2623). The scope was
2644
+ // already re-previewed per candidate here; the ELIGIBLE-ACCOUNT set was not, so a
2645
+ // recovered assignment could select an account that is not entitled to the model
2646
+ // and then fail closed at final auth — the same class of stale-selection bug as
2647
+ // the quota scope, one layer over.
2648
+ subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest(
2649
+ poolAffinityKey,
2650
+ config,
2651
+ previewNow,
2652
+ codexQuotaScopeForModel(modelId),
2653
+ { ...recoverySelectionOptions, modelEligibleAccountIds },
2654
+ );
2655
+ const recoveryPreviewAccountId = subagentFallbackAccountPreview(
2656
+ parsed.modelId,
2657
+ recoveryNow,
2658
+ subagentFallbackModelEligibleAccountIdsForModel?.(parsed.modelId),
2659
+ );
2660
+ return applySubagentModelFallback(
2661
+ parsed,
2662
+ req.headers,
2663
+ config,
2664
+ recoveryPreviewAccountId,
2665
+ recoveryNow,
2666
+ false,
2667
+ recoverySelectionOptions,
2668
+ subagentFallbackAccountPreview,
2669
+ subagentFallbackModelEligibleAccountIdsForModel,
2670
+ );
2671
+ } finally {
2672
+ recoverySelectionAdmission?.release();
2673
+ }
2674
+ })();
2439
2675
  if (fallback) {
2440
2676
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
2441
2677
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
@@ -2558,7 +2794,7 @@ async function handleResponsesInner(
2558
2794
  }
2559
2795
 
2560
2796
  route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
2561
- applyCodexAccountGatedWireNormalization(parsed, route);
2797
+ applyCodexAccountGatedWireNormalization(parsed, route, logCtx);
2562
2798
  logCtx.provider = route.codexAccountNamespace
2563
2799
  ? `${route.providerName}-${route.codexAccountNamespace}`
2564
2800
  : formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
@@ -2580,6 +2816,46 @@ async function handleResponsesInner(
2580
2816
  let replayOAuthCredentialSnapshot: Pick<OAuthAccessSnapshot, "accountId" | "generation"> | undefined;
2581
2817
  let anthropicPoolAccountId: string | null = null;
2582
2818
  let anthropicPoolFailovers = 0;
2819
+ // Generic OAuth rotation (#2568) for providers with no pool of their own. Bound to the account
2820
+ // the request actually used, so a concurrent rotation cannot cool an innocent replacement.
2821
+ let genericFailoverAccountId: string | null = null;
2822
+ let genericFailovers = 0;
2823
+ /**
2824
+ * Apply a rotated account's FULL credential snapshot to the live route (#2568d).
2825
+ *
2826
+ * One helper for all three rotation sites on purpose. Each site used to inline the same four
2827
+ * lines, and the divergence that produced was the bug: `apiKey` was swapped while the routing
2828
+ * metadata paired with it stayed behind.
2829
+ *
2830
+ * Returns false when the snapshot cannot be used safely, and the caller must then abandon the
2831
+ * rotation rather than send a half-applied identity:
2832
+ *
2833
+ * - Copilot pins its bearer to an account-scoped regional origin, so transport is re-resolved
2834
+ * with the new account's `apiBaseUrl` instead of inheriting the previous account's host.
2835
+ * - A Cloud Code Assist provider needs an account-matched project. Antigravity's refresh path
2836
+ * tolerates project discovery failing, so a stored account can legitimately have no project;
2837
+ * sending that account's bearer with the FAILED account's project is worse than not rotating.
2838
+ */
2839
+ const applyFailoverSnapshot = (snapshot: OAuthAccessSnapshot): boolean => {
2840
+ if (route.provider.googleMode === "cloud-code-assist" && !snapshot.projectId) return false;
2841
+ let rotatedProvider: OcxProviderConfig = { ...route.provider, apiKey: snapshot.accessToken };
2842
+ if (route.providerName === "github-copilot") {
2843
+ rotatedProvider = resolveProviderTransport(
2844
+ route.providerName,
2845
+ rotatedProvider,
2846
+ parsed.options.promptCacheKey,
2847
+ snapshot.apiBaseUrl,
2848
+ ) as OcxProviderConfig;
2849
+ }
2850
+ if (snapshot.projectId) rotatedProvider = { ...rotatedProvider, project: snapshot.projectId };
2851
+ route.provider = rotatedProvider;
2852
+ if (route.providerName === "kiro") parsed._kiroAuthContext = { ...(snapshot.kiro ?? {}) };
2853
+ // Re-stamp: a request that rotated accounts must be attributed to the account that actually
2854
+ // served it. All three rotation sites funnel through here, so this is the only re-stamp
2855
+ // needed -- and putting it anywhere else would let one of the three drift.
2856
+ stampOAuthAccountLabel(logCtx, route.providerName, route.provider, snapshot.accountId);
2857
+ return true;
2858
+ };
2583
2859
  const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth"
2584
2860
  ? anthropicSessionKeyFromParts({
2585
2861
  sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
@@ -2619,6 +2895,15 @@ async function handleResponsesInner(
2619
2895
  };
2620
2896
  if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
2621
2897
  route.provider = { ...route.provider, apiKey: resolved.accessToken };
2898
+ // Attribution is independent of failover (#2699): stamped from the resolved snapshot
2899
+ // itself, not from inside the `isGenericFailoverProvider` branch below, so a future
2900
+ // narrowing of that predicate cannot silently switch attribution off.
2901
+ stampOAuthAccountLabel(logCtx, route.providerName, route.provider, resolved.accountId);
2902
+ // Remember which account actually served this request so a 429 cools THAT one, not
2903
+ // whichever account is active by the time the response comes back (#2568).
2904
+ if (isGenericFailoverProvider(route.providerName, route.provider)) {
2905
+ genericFailoverAccountId = resolved.accountId;
2906
+ }
2622
2907
  if (route.providerName === "kiro") {
2623
2908
  // `{}` is intentional: this is an account-scoped request with no stored routing metadata.
2624
2909
  // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback.
@@ -2697,6 +2982,7 @@ async function handleResponsesInner(
2697
2982
  (logCtx.attempts ??= []).push(attempt);
2698
2983
  }
2699
2984
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name, logCtx.accountLogLabel);
2985
+ let runTurnAdapter = adapter;
2700
2986
  if (adapter.runTurn) {
2701
2987
  recordAdapterTierMetadata(logCtx, adapter.tierLogForRunTurn?.(parsed));
2702
2988
  }
@@ -2825,8 +3111,9 @@ async function handleResponsesInner(
2825
3111
  delete parsed.options.parallelToolCalls;
2826
3112
  // The compaction turn is a plain prose summary; a surviving structured-output format
2827
3113
  // would force schema-constrained JSON into the synthetic compaction item. The flag and
2828
- // the raw `text` controls go too: Kiro's capability guard reads both and would reject
2829
- // the turn outright, and the key-mode openai-responses adapter builds from _rawBody.
3114
+ // the raw `text` control go too: the key-mode openai-responses adapter builds from
3115
+ // _rawBody, so a surviving format there would still reach the upstream. (The Kiro
3116
+ // guard no longer reads _rawBody.text; it refuses structured output only.)
2830
3117
  delete parsed.options.textFormat;
2831
3118
  delete parsed._structuredOutput;
2832
3119
  if (parsed._rawBody && typeof parsed._rawBody === "object") {
@@ -2848,6 +3135,7 @@ async function handleResponsesInner(
2848
3135
  ? new Map<string, { namespace: string; name: string }>()
2849
3136
  : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget);
2850
3137
  const routedCustomToolNames = new Set<string>();
3138
+ const routedCustomToolRepairNames = new Set<string>();
2851
3139
  const routedToolSearchNames = new Set<string>();
2852
3140
  // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
2853
3141
  // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
@@ -2870,6 +3158,23 @@ async function handleResponsesInner(
2870
3158
  + `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`,
2871
3159
  );
2872
3160
  }
3161
+ // Preserve the caller's readable catalog boundary before provider-specific normalization can
3162
+ // remove an unsupported final entry (for example xAI cached-only web search).
3163
+ const replayedInputPrefixLength = parsed._replayPrefixLen ?? 0;
3164
+ const clientToolAuthorizationBody = currentTurnWireToolCatalogBody(
3165
+ parsed._rawBody,
3166
+ replayedInputPrefixLength,
3167
+ );
3168
+ const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(clientToolAuthorizationBody);
3169
+ const clientDeclaredWireToolNames = collectDeclaredWireToolNames(clientToolAuthorizationBody);
3170
+ const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes(
3171
+ clientToolAuthorizationBody,
3172
+ );
3173
+ // Hosted calls the PROVIDER runs itself. Gated on the destination actually being xAI, so a
3174
+ // declaration alone cannot buy the exemption on some other upstream that never serves it.
3175
+ const providerExecutedCallTypes = isXaiResponsesDestination(route.provider)
3176
+ ? collectProviderExecutedCallTypes(clientToolAuthorizationBody)
3177
+ : new Set<ProviderExecutedCallType>();
2873
3178
  let request: Awaited<ReturnType<typeof adapter.buildRequest>>;
2874
3179
  try {
2875
3180
  request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
@@ -2879,7 +3184,9 @@ async function handleResponsesInner(
2879
3184
  // the rotation-rebuild and bridged paths already answer 400 for the identical throw. Rethrowing
2880
3185
  // it here escaped every catch up to the Bun handler, so the same request produced an
2881
3186
  // unstructured 500 — and no request log — depending only on whether a rotation ran first.
2882
- if (error instanceof NamespaceToolCollisionError) {
3187
+ // Same shape for a tool_choice this proxy cannot honor: the destination rejects a schema the
3188
+ // catalog had to drop, so the selector naming it is a client input error, not a 500.
3189
+ if (error instanceof NamespaceToolCollisionError || error instanceof XaiToolSchemaCompatibilityError) {
2883
3190
  return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message));
2884
3191
  }
2885
3192
  throw error;
@@ -2891,6 +3198,12 @@ async function handleResponsesInner(
2891
3198
  || toolBridgeMaps.toolNsMap.get(name)?.freeform === true
2892
3199
  ) routedCustomToolNames.add(name);
2893
3200
  }
3201
+ for (const name of request.routedCustomToolRepairNames ?? []) {
3202
+ if (
3203
+ toolBridgeMaps.freeformToolNames.has(name)
3204
+ || toolBridgeMaps.toolNsMap.get(name)?.freeform === true
3205
+ ) routedCustomToolRepairNames.add(name);
3206
+ }
2894
3207
  }
2895
3208
  for (const name of request.convertedRoutedToolSearchNames ?? []) {
2896
3209
  // The adapter already keeps this set empty when tool_choice forbids the private search.
@@ -2906,13 +3219,13 @@ async function handleResponsesInner(
2906
3219
  // call it cannot execute, and the turn showed a bare `aborted` with the file untouched.
2907
3220
  // Forward auth is the canonical ChatGPT backend speaking Codex's own protocol rather than a
2908
3221
  // routed provider, so it keeps passing through unguarded, as it does for the rewrites above.
2909
- // The guard needs a catalog to compare against, so it stands down when the request carries
2910
- // none. That is not the same claim as "no tools means no tool may be called": a passthrough
2911
- // request can legitimately omit `tools` entirely and still receive a tool call the client
2912
- // understands — `tests/github-copilot-stream-contract.test.ts` sends `{model, input, stream}`
2913
- // with no tools and Copilot answers with a `custom_tool_call` for `apply_patch`. Policing an
2914
- // empty catalog truncates that turn. An unreadable body lands here too, since it yields no
2915
- // names either.
3222
+ // The guard needs a catalog to compare against, so it stands down when the request omits one.
3223
+ // An explicit empty catalog is still authoritative: it declares that no client tools may be
3224
+ // called. A passthrough request can legitimately omit `tools` entirely and still receive a call
3225
+ // the client understands — `tests/github-copilot-stream-contract.test.ts` sends
3226
+ // `{model, input, stream}` with no tools and Copilot answers with a `custom_tool_call` for
3227
+ // `apply_patch`. Policing an absent catalog truncates that turn. An unreadable body lands there
3228
+ // too because the proxy cannot establish the caller's declared authorization boundary.
2916
3229
  const parseOutboundRequestBody = (bodyText: string): Record<string, unknown> | undefined => {
2917
3230
  try {
2918
3231
  const body = JSON.parse(bodyText) as unknown;
@@ -2923,16 +3236,46 @@ async function handleResponsesInner(
2923
3236
  return undefined;
2924
3237
  }
2925
3238
  };
2926
- let outboundRequestBody = parseOutboundRequestBody(request.body);
2927
- const declaredWireToolNames = collectDeclaredWireToolNames(outboundRequestBody);
2928
- // Union with the caller's own catalog, because the outbound body is not always a complete
2929
- // record of it: hosted-tool preference REPLACES a client tool with its hosted form, so a
2930
- // request declaring `image_gen.generate` ships `{type:"image_generation"}` upstream and gets
2931
- // the client's name back. Widening only ever makes the guard fire less; a name declared in
2932
- // neither place — #1700's `apply_patch` — is still refused.
2933
- for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name);
2934
- const undeclaredToolGuardActive = declaredWireToolNames.size > 0
2935
- && route.provider.authMode !== "forward";
3239
+ let outboundRequestBody: Record<string, unknown> | undefined;
3240
+ const declaredWireToolNames = new Set<string>();
3241
+ const declaredNamelessClientCallTypes = new Set<string>();
3242
+ let undeclaredToolGuardActive = false;
3243
+ const refreshUndeclaredToolGuard = (builtRequest: AdapterRequest): void => {
3244
+ outboundRequestBody = parseOutboundRequestBody(builtRequest.body);
3245
+ declaredWireToolNames.clear();
3246
+ // With no replay prefix the full outbound body belongs to this turn and its normalized
3247
+ // aliases are authoritative. A continuation's outbound body still contains historical
3248
+ // catalogs (and may promote historical tool-search definitions), so it can never widen the
3249
+ // current caller snapshot captured above.
3250
+ if (replayedInputPrefixLength === 0) {
3251
+ for (const name of collectDeclaredWireToolNames(outboundRequestBody)) {
3252
+ declaredWireToolNames.add(name);
3253
+ }
3254
+ }
3255
+ for (const name of clientDeclaredWireToolNames) declaredWireToolNames.add(name);
3256
+ declaredNamelessClientCallTypes.clear();
3257
+ if (replayedInputPrefixLength === 0) {
3258
+ for (const callType of collectDeclaredNamelessClientCallTypes(outboundRequestBody)) {
3259
+ declaredNamelessClientCallTypes.add(callType);
3260
+ }
3261
+ }
3262
+ for (const callType of clientDeclaredNamelessCallTypes) {
3263
+ declaredNamelessClientCallTypes.add(callType);
3264
+ }
3265
+ // On an ordinary request these maps capture caller-catalog identities that normalization may
3266
+ // replace on the outbound wire (for example a client image tool becoming hosted). On replay,
3267
+ // however, the parsed maps also contain historical catalog entries, so only the bounded
3268
+ // current-turn wire snapshot above may authorize a call.
3269
+ if (replayedInputPrefixLength === 0) {
3270
+ for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name);
3271
+ }
3272
+ undeclaredToolGuardActive = (
3273
+ declaredWireToolNames.size > 0
3274
+ || clientDeclaredNamelessCallTypes.size > 0
3275
+ || clientExplicitWireToolCatalog
3276
+ ) && route.provider.authMode !== "forward";
3277
+ };
3278
+ refreshUndeclaredToolGuard(request);
2936
3279
  // A refused turn must not seed `previous_response_id` replay. The inspection branch reads the
2937
3280
  // untouched upstream stream, so it can still observe a `response.completed` the client never
2938
3281
  // received; checking the payload itself rather than a flag shared with the client relay keeps
@@ -2949,20 +3292,36 @@ async function handleResponsesInner(
2949
3292
  // provider) every name looks undeclared, and flipping this would stop recording continuation
2950
3293
  // state for exactly the passthrough traffic the guard deliberately stands down for.
2951
3294
  if (!undeclaredToolGuardActive || inspectionSawUndeclaredTool) return;
2952
- if (undeclaredToolCallName(payload, declaredWireToolNames) !== undefined) {
3295
+ if (undeclaredToolCallName(
3296
+ payload,
3297
+ declaredWireToolNames,
3298
+ declaredNamelessClientCallTypes,
3299
+ providerExecutedCallTypes,
3300
+ ) !== undefined) {
2953
3301
  inspectionSawUndeclaredTool = true;
2954
3302
  }
2955
3303
  };
2956
3304
  const rememberPassthroughResponseChecked = rememberPassthroughResponse
2957
3305
  ? (response: { id?: unknown; output?: unknown; status?: unknown }) => {
2958
3306
  if (inspectionSawUndeclaredTool) return;
3307
+ const restoredResponse = restoreRoutedCustomCalls(
3308
+ response,
3309
+ routedCustomToolNames,
3310
+ routedCustomToolRepairNames,
3311
+ declaredWireToolNames,
3312
+ ).value as { id?: unknown; output?: unknown; status?: unknown };
2959
3313
  if (
2960
3314
  undeclaredToolGuardActive
2961
- && undeclaredToolCallNameInResponse(response, declaredWireToolNames) !== undefined
3315
+ && undeclaredToolCallNameInResponse(
3316
+ restoredResponse,
3317
+ declaredWireToolNames,
3318
+ declaredNamelessClientCallTypes,
3319
+ providerExecutedCallTypes,
3320
+ ) !== undefined
2962
3321
  ) {
2963
3322
  return;
2964
3323
  }
2965
- rememberPassthroughResponse(response);
3324
+ rememberPassthroughResponse(restoredResponse);
2966
3325
  }
2967
3326
  : undefined;
2968
3327
  recordAdapterReasoning(logCtx, request);
@@ -3125,7 +3484,7 @@ async function handleResponsesInner(
3125
3484
  ? request.usageLog.inputTokens
3126
3485
  : undefined;
3127
3486
  if (passthroughEstimate !== undefined) logCtx.usageLogInputTokens = passthroughEstimate;
3128
- outboundRequestBody = parseOutboundRequestBody(request.body);
3487
+ refreshUndeclaredToolGuard(request);
3129
3488
  logCtx.providerAdapter = retryAdapter.name;
3130
3489
  sealRequestAttemptIdentity(
3131
3490
  logCtx.activeAttempt,
@@ -3235,6 +3594,7 @@ async function handleResponsesInner(
3235
3594
  const msg = err instanceof Error ? err.message : String(err);
3236
3595
  return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg));
3237
3596
  }
3597
+ refreshUndeclaredToolGuard(request);
3238
3598
  try {
3239
3599
  upstreamResponse = await fetchWithTransientRetry(
3240
3600
  recovery => {
@@ -3386,6 +3746,7 @@ async function handleResponsesInner(
3386
3746
  authCtx = retry.authCtx;
3387
3747
  request = retry.request;
3388
3748
  refreshRoutedNamespaceToolAliases(request);
3749
+ refreshUndeclaredToolGuard(request);
3389
3750
  upstreamResponse = retry.upstreamResponse;
3390
3751
  selectedForwardHeaders = retry.selectedForwardHeaders;
3391
3752
  // Keep subagent quota-failure health keyed to the account that actually served.
@@ -3414,7 +3775,7 @@ async function handleResponsesInner(
3414
3775
  }
3415
3776
  const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
3416
3777
  const resolvedModel = headers.get("openai-model")?.trim();
3417
- if (resolvedModel) logCtx.resolvedModel = resolvedModel;
3778
+ if (resolvedModel && !logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel;
3418
3779
  if (isUsageDebugEnabled()) {
3419
3780
  const upstreamContentType = upstreamResponse.headers.get("content-type");
3420
3781
  if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType;
@@ -3424,13 +3785,21 @@ async function handleResponsesInner(
3424
3785
  const passthroughCt = headers.get("content-type")?.toLowerCase();
3425
3786
  const isEventStream = passthroughCt?.includes("text/event-stream")
3426
3787
  || (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream);
3427
- const terminalRecorder = codexForwardTerminalOutcomeRecorder(
3788
+ const recordTerminalOutcome = codexForwardTerminalOutcomeRecorder(
3428
3789
  config,
3429
3790
  authCtx,
3430
3791
  route.provider,
3431
3792
  route.modelId,
3432
3793
  logCtx,
3433
3794
  );
3795
+ let terminalOutcomeRecorded = false;
3796
+ const terminalRecorder = recordTerminalOutcome
3797
+ ? (status: ResponsesTerminalStatus, httpStatusOverride?: number): void => {
3798
+ if (terminalOutcomeRecorded) return;
3799
+ terminalOutcomeRecorded = true;
3800
+ recordTerminalOutcome(status, httpStatusOverride);
3801
+ }
3802
+ : undefined;
3434
3803
  const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
3435
3804
  // Capture quota from upstream response for multi-account tracking
3436
3805
  if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
@@ -3507,19 +3876,13 @@ async function handleResponsesInner(
3507
3876
  options.onConsumedComboFailure?.(failure);
3508
3877
  return failure.response;
3509
3878
  }
3510
- // The plain passthrough error path has no bounded reader of its own: `.text()` attaches
3511
- // the reader only when it runs, so an abort landing between fetch resolution and that
3512
- // call orphans Bun's internal read rejection (src/lib/abort.ts).
3513
- const detachPassthroughErrorGuard = cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
3514
- try {
3515
- const errorText = await upstreamResponse.text().catch(() => "");
3516
- return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
3517
- statusText: upstreamResponse.statusText,
3518
- headers,
3519
- });
3520
- } finally {
3521
- detachPassthroughErrorGuard();
3522
- }
3879
+ // The bounded reader owns the original body, deadline, abort settlement, and lock.
3880
+ // Unsafe partial data falls back to #452's non-empty status-only JSON.
3881
+ const errorText = await readDisplaySafeErrorText(upstreamResponse, upstream.signal, "");
3882
+ return formatPassthroughUpstreamError(upstreamResponse.status, errorText, {
3883
+ statusText: upstreamResponse.statusText,
3884
+ headers,
3885
+ });
3523
3886
  }
3524
3887
 
3525
3888
  // Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
@@ -3582,8 +3945,13 @@ async function handleResponsesInner(
3582
3945
  payloadRewrites.length > 0
3583
3946
  ? payloadRewriteAsBlockRewrite(composeSsePayloadRewrites(...payloadRewrites))
3584
3947
  : undefined,
3585
- routedCustomToolNames.size > 0
3586
- ? createRoutedCustomToolRestoreBlockRewrite(routedCustomToolNames, translatorBudget)
3948
+ routedCustomToolNames.size > 0 || routedCustomToolRepairNames.size > 0
3949
+ ? createRoutedCustomToolRestoreBlockRewrite(
3950
+ routedCustomToolNames,
3951
+ translatorBudget,
3952
+ routedCustomToolRepairNames,
3953
+ declaredWireToolNames,
3954
+ )
3587
3955
  : undefined,
3588
3956
  routedToolSearchNames.size > 0
3589
3957
  ? createRoutedToolSearchRestoreBlockRewrite(routedToolSearchNames, translatorBudget)
@@ -3598,7 +3966,11 @@ async function handleResponsesInner(
3598
3966
  // Last: every rewrite above can still rename or reshape a call item, so the guard must
3599
3967
  // compare the names the client will actually receive against the declared catalog.
3600
3968
  undeclaredToolGuardActive
3601
- ? createUndeclaredToolCallGuardBlockRewrite(declaredWireToolNames)
3969
+ ? createUndeclaredToolCallGuardBlockRewrite(
3970
+ declaredWireToolNames,
3971
+ declaredNamelessClientCallTypes,
3972
+ providerExecutedCallTypes,
3973
+ )
3602
3974
  : undefined,
3603
3975
  ].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
3604
3976
  const clientBlockRewrite = blockRewrites.length > 0
@@ -3788,6 +4160,8 @@ async function handleResponsesInner(
3788
4160
  const restored = restoreRoutedCustomCallsInJson(
3789
4161
  restoredNamespace,
3790
4162
  routedCustomToolNames,
4163
+ routedCustomToolRepairNames,
4164
+ declaredWireToolNames,
3791
4165
  );
3792
4166
  const restoredToolSearch = restoreRoutedToolSearchCallsInJson(
3793
4167
  restored,
@@ -3814,7 +4188,12 @@ async function handleResponsesInner(
3814
4188
  if (undeclaredToolGuardActive) {
3815
4189
  const undeclared = (() => {
3816
4190
  try {
3817
- return undeclaredToolCallNameInResponse(JSON.parse(clientJson), declaredWireToolNames);
4191
+ return undeclaredToolCallNameInResponse(
4192
+ JSON.parse(clientJson),
4193
+ declaredWireToolNames,
4194
+ declaredNamelessClientCallTypes,
4195
+ providerExecutedCallTypes,
4196
+ );
3818
4197
  } catch {
3819
4198
  return undefined;
3820
4199
  }
@@ -3939,6 +4318,49 @@ async function handleResponsesInner(
3939
4318
  const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined;
3940
4319
  const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined;
3941
4320
  const canRunWebSearch = !!wsPlan && !adapter.runTurn;
4321
+ const rotateSidecarProviderOn429 = async (retryAfter: string | null): Promise<ProviderAdapter | null> => {
4322
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
4323
+ retryAfter,
4324
+ now: Date.now(),
4325
+ attemptedKey: route.provider.apiKey,
4326
+ promptCacheKey: parsed.options.promptCacheKey,
4327
+ });
4328
+ if (rotated) {
4329
+ route.provider = rotated;
4330
+ } else {
4331
+ if (
4332
+ !genericFailoverAccountId
4333
+ || genericFailovers >= GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
4334
+ || !isGenericOAuthFailoverEnabled(config, route.providerName)
4335
+ ) return null;
4336
+ const nextAccountId = rotateGenericOAuthAccountOn429(
4337
+ config,
4338
+ route.providerName,
4339
+ genericFailoverAccountId,
4340
+ retryAfter,
4341
+ );
4342
+ if (!nextAccountId) return null;
4343
+ try {
4344
+ const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
4345
+ genericFailoverAccountId = nextAccountId;
4346
+ genericFailovers += 1;
4347
+ if (!applyFailoverSnapshot(snapshot)) return null;
4348
+ } catch {
4349
+ return null;
4350
+ }
4351
+ }
4352
+ const rotatedAdapter = resolveAdapter(
4353
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
4354
+ config.cacheRetention,
4355
+ );
4356
+ bindRouteReasoningReplayScope({
4357
+ parsed,
4358
+ providerName: route.providerName,
4359
+ provider: route.provider,
4360
+ adapterName: rotatedAdapter.name,
4361
+ });
4362
+ return rotatedAdapter;
4363
+ };
3942
4364
  if ((imgPlan || vidPlan) && canRunWebSearch) {
3943
4365
  // Web search takes priority when both are active — the media bridge cannot run
3944
4366
  // alongside runWithWebSearch. Surface a runtime signal so the user knows their
@@ -4029,32 +4451,13 @@ async function handleResponsesInner(
4029
4451
  if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
4030
4452
  }
4031
4453
  },
4032
- on429: retryAfter => {
4033
- const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
4034
- retryAfter,
4035
- now: Date.now(),
4036
- attemptedKey: route.provider.apiKey,
4037
- promptCacheKey: parsed.options.promptCacheKey,
4038
- });
4039
- if (!rotated) return null;
4040
- route.provider = rotated;
4041
- const rotatedAdapter = resolveAdapter(
4042
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
4043
- config.cacheRetention,
4044
- );
4045
- bindRouteReasoningReplayScope({
4046
- parsed,
4047
- providerName: route.providerName,
4048
- provider: route.provider,
4049
- adapterName: rotatedAdapter.name,
4050
- });
4051
- return rotatedAdapter;
4052
- },
4454
+ on429: rotateSidecarProviderOn429,
4053
4455
  retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
4054
4456
  ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
4055
4457
  ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
4056
4458
  onCompletedResponse: (response, providerState) => {
4057
4459
  commitReasoningReplayServingRoute();
4460
+ rememberKiroDeliveredFinalAnswer(adapter.name, response);
4058
4461
  rememberResponseState(
4059
4462
  parsed._rawBody,
4060
4463
  response,
@@ -4117,27 +4520,7 @@ async function handleResponsesInner(
4117
4520
  routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
4118
4521
  stallTimeoutSec: wsPlan.stallTimeoutSec,
4119
4522
  streamRoutedModelOutput: wsPlan.streamRoutedModelOutput,
4120
- on429: retryAfter => {
4121
- const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
4122
- retryAfter,
4123
- now: Date.now(),
4124
- attemptedKey: route.provider.apiKey,
4125
- promptCacheKey: parsed.options.promptCacheKey,
4126
- });
4127
- if (!rotated) return null;
4128
- route.provider = rotated;
4129
- const rotatedAdapter = resolveAdapter(
4130
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
4131
- config.cacheRetention,
4132
- );
4133
- bindRouteReasoningReplayScope({
4134
- parsed,
4135
- providerName: route.providerName,
4136
- provider: route.provider,
4137
- adapterName: rotatedAdapter.name,
4138
- });
4139
- return rotatedAdapter;
4140
- },
4523
+ on429: rotateSidecarProviderOn429,
4141
4524
  retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
4142
4525
  onCompletedResponse: commitReasoningReplayServingRoute,
4143
4526
  });
@@ -4209,7 +4592,7 @@ async function handleResponsesInner(
4209
4592
  pacingSlotAcquired: true,
4210
4593
  },
4211
4594
  );
4212
- await adapter.runTurn?.(
4595
+ await runTurnAdapter.runTurn?.(
4213
4596
  parsed,
4214
4597
  {
4215
4598
  headers: selectedForwardHeaders,
@@ -4242,6 +4625,77 @@ async function handleResponsesInner(
4242
4625
  }
4243
4626
  };
4244
4627
  const runTurn = async (): Promise<void> => runTurnAttempt(queue, undefined, true);
4628
+ const rotateRunTurnAdapterOnPreflight429 = async (
4629
+ error: Extract<AdapterEvent, { type: "error" }>,
4630
+ ): Promise<boolean> => {
4631
+ const status = error.status ?? adapterFailureFromMessage(error.message).httpStatus;
4632
+ if (
4633
+ status !== 429
4634
+ || !genericFailoverAccountId
4635
+ || genericFailovers >= GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
4636
+ || !isGenericOAuthFailoverEnabled(config, route.providerName)
4637
+ ) return false;
4638
+ const nextAccountId = rotateGenericOAuthAccountOn429(
4639
+ config,
4640
+ route.providerName,
4641
+ genericFailoverAccountId,
4642
+ null,
4643
+ );
4644
+ if (!nextAccountId) return false;
4645
+ try {
4646
+ const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
4647
+ genericFailoverAccountId = nextAccountId;
4648
+ genericFailovers += 1;
4649
+ if (!applyFailoverSnapshot(snapshot)) return false;
4650
+ // A Cursor conversation/checkpoint is credential-scoped. The failed attempt emitted no
4651
+ // client-visible bytes, so replay is safe, but carrying its account identity into the next
4652
+ // account would not be. Let the rotated adapter derive a fresh identity and conversation.
4653
+ parsed._cursorIdentityScope = undefined;
4654
+ parsed._cursorConversationId = undefined;
4655
+ if (parsed._providerContinuation?.cursor) {
4656
+ const { cursor: _discardedCursor, ...otherProviderState } = parsed._providerContinuation;
4657
+ parsed._providerContinuation = otherProviderState;
4658
+ }
4659
+ const rotatedProvider = resolveWireProtocolOverride(
4660
+ route.providerName,
4661
+ route.modelId,
4662
+ route.provider,
4663
+ inboundWire,
4664
+ );
4665
+ const rotatedAdapter = resolveAdapter(rotatedProvider, config.cacheRetention);
4666
+ if (!rotatedAdapter.runTurn) return false;
4667
+ runTurnAdapter = rotatedAdapter;
4668
+ bindRouteReasoningReplayScope({
4669
+ parsed,
4670
+ providerName: route.providerName,
4671
+ provider: rotatedProvider,
4672
+ adapterName: rotatedAdapter.name,
4673
+ oauthCredentialSnapshot: { accountId: snapshot.accountId, generation: snapshot.generation },
4674
+ codexAuthContext: authCtx,
4675
+ forwardHeaders: selectedForwardHeaders,
4676
+ });
4677
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, rotatedAdapter.name, logCtx.accountLogLabel);
4678
+ return true;
4679
+ } catch {
4680
+ return false;
4681
+ }
4682
+ };
4683
+ const preflightRunTurnFailover = async (
4684
+ firstSource: AsyncIterable<AdapterEvent>,
4685
+ ): Promise<AsyncIterable<AdapterEvent>> => {
4686
+ let source = firstSource;
4687
+ while (true) {
4688
+ const preflight = await preflightAdapterEvents(source);
4689
+ if (!preflight.error || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) {
4690
+ return preflight.stream;
4691
+ }
4692
+ const retryQueue = createAdapterEventQueue({
4693
+ onBacklogExceeded: () => runTurnAbort.abort(),
4694
+ });
4695
+ void runTurnAttempt(retryQueue, "oauth-account-429");
4696
+ source = retryQueue.stream();
4697
+ }
4698
+ };
4245
4699
  // The empty-completion retry re-runs the turn against a fresh queue: the
4246
4700
  // first queue is closed once its attempt settles, and pushing into it after
4247
4701
  // close is a silent no-op.
@@ -4257,6 +4711,11 @@ async function handleResponsesInner(
4257
4711
  if (parsed.stream) {
4258
4712
  void runTurn();
4259
4713
  let eventSource: AsyncIterable<AdapterEvent> = queue.stream();
4714
+ if (genericFailoverAccountId && isGenericOAuthFailoverEnabled(config, route.providerName)) {
4715
+ // Preflight holds only heartbeats and the first meaningful event. A first-event 429 can be
4716
+ // replayed transparently; after any output reaches the bridge, a later error stays terminal.
4717
+ eventSource = await preflightRunTurnFailover(eventSource);
4718
+ }
4260
4719
  if (options.comboAttempt) {
4261
4720
  const preflight = await preflightAdapterEvents(eventSource);
4262
4721
  if (preflight.error || preflight.empty) {
@@ -4274,7 +4733,16 @@ async function handleResponsesInner(
4274
4733
  // signal — run the adapter transport again against a fresh queue.
4275
4734
  continuation: runTurnRetrySource,
4276
4735
  })
4277
- : eventSource;
4736
+ // Guard off (the default): leave the stream alone, but record that the turn ended
4737
+ // empty so the user has something to correlate instead of an unexplained blank
4738
+ // result (#2472). Retrying by default would re-send a turn that may already have had
4739
+ // billable side effects, so the honest default is observability, not recovery.
4740
+ : observeEmptyCompletion(eventSource, () => {
4741
+ console.warn(
4742
+ `[opencodex] ${route.providerName}/${route.modelId} completed with no output text `
4743
+ + "and no tool call. Set \"emptyCompletionRetry\": true to retry such turns once.",
4744
+ );
4745
+ });
4278
4746
  const sseStream = bridgeToResponsesSSE(
4279
4747
  guardedSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
4280
4748
  () => {
@@ -4305,6 +4773,7 @@ async function handleResponsesInner(
4305
4773
  },
4306
4774
  onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
4307
4775
  commitReasoningReplayServingRoute();
4776
+ rememberKiroDeliveredFinalAnswer(adapter.name, response);
4308
4777
  if (!routedCompaction) {
4309
4778
  rememberResponseState(
4310
4779
  parsed._rawBody,
@@ -4318,22 +4787,31 @@ async function handleResponsesInner(
4318
4787
  );
4319
4788
  const bridgeTurnAc = new AbortController();
4320
4789
  const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, undefined, options.turnAdmissionLease);
4321
- return new Response(trackedSse, {
4790
+ const response = new Response(trackedSse, {
4322
4791
  headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
4323
4792
  });
4793
+ runTurnAdapterSseResponses.add(response);
4794
+ return response;
4324
4795
  }
4325
4796
 
4326
4797
  await runTurn();
4327
4798
  const firstAttemptEvents = await queue.collect();
4799
+ let runTurnEvents: AdapterEvent[] = firstAttemptEvents;
4800
+ if (genericFailoverAccountId && isGenericOAuthFailoverEnabled(config, route.providerName)) {
4801
+ runTurnEvents = [];
4802
+ for await (const event of await preflightRunTurnFailover(
4803
+ (async function* () { yield* firstAttemptEvents; })(),
4804
+ )) runTurnEvents.push(event);
4805
+ }
4328
4806
  let events: AdapterEvent[];
4329
4807
  if (emptyCompletionGuardEnabled) {
4330
4808
  events = [];
4331
4809
  for await (const event of guardEmptyCompletionEventStream({
4332
- firstEvents: (async function* () { yield* firstAttemptEvents; })(),
4810
+ firstEvents: (async function* () { yield* runTurnEvents; })(),
4333
4811
  continuation: runTurnRetrySource,
4334
4812
  })) events.push(event);
4335
4813
  } else {
4336
- events = firstAttemptEvents;
4814
+ events = runTurnEvents;
4337
4815
  }
4338
4816
  if (options.comboAttempt) {
4339
4817
  const firstMeaningful = events.find(event => event.type !== "heartbeat");
@@ -4365,6 +4843,7 @@ async function handleResponsesInner(
4365
4843
  },
4366
4844
  });
4367
4845
  if (!routedCompaction) {
4846
+ rememberKiroDeliveredFinalAnswer(adapter.name, json);
4368
4847
  rememberResponseState(
4369
4848
  parsed._rawBody,
4370
4849
  json,
@@ -4399,6 +4878,64 @@ async function handleResponsesInner(
4399
4878
  // reuse is safe, and releaseBodyObservation is idempotent per build.
4400
4879
  let initialRequest: AdapterRequest | undefined;
4401
4880
  let inputTokenEstimate: number | undefined;
4881
+ // An adapter may know the turn needs no inference at all — Kiro's replayed history ending in a
4882
+ // delivered final answer. Answer it locally: no build (so no token estimate), no send (so
4883
+ // sendCount stays 0), and crucially no empty-completion guard, which treats an outputless
4884
+ // terminal as a failed turn and re-invokes the identical request. Routing this through the
4885
+ // ordinary event path would therefore reinstate the loop it exists to end.
4886
+ const localTerminal = activeAdapter.localTerminal?.(parsed);
4887
+ if (localTerminal) {
4888
+ logCtx.localTerminalReason = localTerminal.reason;
4889
+ // Mark the physical attempt too, not just the parent row. `finishRequestAttempt` finalizes the
4890
+ // attempt through the same estimated-provider path, so without this the row reads exact while
4891
+ // its own attempt still claims an estimate — the detailed accounting a maintainer actually
4892
+ // reads for a zero-send turn.
4893
+ if (logCtx.activeAttempt) logCtx.activeAttempt.locallyAnswered = true;
4894
+ cleanupUpstreamAbort();
4895
+ upstream.abort();
4896
+ const terminalEvents: AdapterEvent[] = [{
4897
+ type: "done",
4898
+ endTurn: true,
4899
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
4900
+ }];
4901
+ if (parsed.stream) {
4902
+ const localSse = bridgeToResponsesSSE(
4903
+ (async function* () { yield* terminalEvents; })(),
4904
+ parsed._responseModelId ?? parsed.modelId,
4905
+ toolBridgeMaps.toolNsMap,
4906
+ toolBridgeMaps.freeformToolNames,
4907
+ toolBridgeMaps.toolSearchToolNames,
4908
+ undefined,
4909
+ 2_000,
4910
+ {
4911
+ translatorBudget,
4912
+ ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
4913
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
4914
+ },
4915
+ );
4916
+ // Same lifetime tracking as every other streaming return in this function: the turn
4917
+ // admission lease is released when the body finishes or the client disconnects. Returning
4918
+ // the raw stream would hold a lease for a turn that already has all of its output.
4919
+ const localTurnAc = new AbortController();
4920
+ return new Response(
4921
+ trackStreamLifetime(localSse, localTurnAc, undefined, options.turnAdmissionLease),
4922
+ {
4923
+ headers: {
4924
+ "Content-Type": "text/event-stream",
4925
+ "Cache-Control": "no-cache",
4926
+ "Connection": "keep-alive",
4927
+ "X-Accel-Buffering": "no",
4928
+ },
4929
+ },
4930
+ );
4931
+ }
4932
+ return new Response(
4933
+ JSON.stringify(buildResponseJSON(terminalEvents, parsed._responseModelId ?? parsed.modelId, {
4934
+ translatorBudget,
4935
+ })),
4936
+ { headers: { "Content-Type": "application/json" } },
4937
+ );
4938
+ }
4402
4939
  try {
4403
4940
  initialRequest = await activeAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
4404
4941
  refreshRoutedNamespaceToolAliases(initialRequest);
@@ -4732,6 +5269,49 @@ async function handleResponsesInner(
4732
5269
  break;
4733
5270
  }
4734
5271
  }
5272
+ // Generic OAuth account failover (#2568) for providers with no pool of their own.
5273
+ // Presence is consent since #2568d: rotation is ON by default once two or more eligible
5274
+ // accounts are stored for the provider, because a second deliberate login is read as the
5275
+ // operator asking for it. A single-account install is still a strict no-op, and an
5276
+ // explicit `oauthAccountFailover.enabled: false` (global or per provider) still wins --
5277
+ // see isGenericOAuthFailoverEnabled in src/oauth/generic-account-failover.ts. Codex and
5278
+ // Anthropic are excluded by isGenericFailoverProvider: their pools own quota scopes,
5279
+ // probe leases and affinity that this must not reimplement.
5280
+ while (
5281
+ upstreamResponse.status === 429
5282
+ && genericFailoverAccountId
5283
+ && genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
5284
+ && isGenericOAuthFailoverEnabled(config, route.providerName)
5285
+ ) {
5286
+ const nextAccountId = rotateGenericOAuthAccountOn429(
5287
+ config,
5288
+ route.providerName,
5289
+ genericFailoverAccountId,
5290
+ upstreamResponse.headers.get("retry-after"),
5291
+ );
5292
+ if (!nextAccountId) break;
5293
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
5294
+ try {
5295
+ // The FULL snapshot, not just the bearer: Antigravity pairs an account-matched
5296
+ // projectId with its token and Kiro carries routing metadata, so a token-only swap
5297
+ // would mix one account's credential with another's routing data.
5298
+ const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
5299
+ genericFailoverAccountId = nextAccountId;
5300
+ genericFailovers += 1;
5301
+ if (!applyFailoverSnapshot(snapshot)) break;
5302
+ invalidateSameTargetRequest();
5303
+ activeAdapter = resolveAdapter(
5304
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
5305
+ config.cacheRetention,
5306
+ );
5307
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
5308
+ const result = await rebuildAndRefetch("oauth-account-429");
5309
+ if ("failed" in result) return result.failed;
5310
+ upstreamResponse = result;
5311
+ } catch {
5312
+ break;
5313
+ }
5314
+ }
4735
5315
  // Unknown provenance is deliberately fail-soft in pre-flight: after a restart, TTL expiry,
4736
5316
  // or LRU eviction, a valid same-backend blob must survive. A decoder's own 4xx identity is
4737
5317
  // the missing authoritative signal. Rebuild once through the same sanitation path used by a
@@ -4783,14 +5363,16 @@ async function handleResponsesInner(
4783
5363
  options.onConsumedComboFailure?.(failure);
4784
5364
  return failure.response;
4785
5365
  }
4786
- // The plain error path has no bounded reader of its own: `.text()` attaches the reader
4787
- // only when it runs, so an abort landing between fetch resolution and that call orphans
4788
- // Bun's internal read rejection — the uncatchable teardown `cancelBodyOnAbort` absorbs
4789
- // (src/lib/abort.ts). Found while investigating #1419; not a fix for the native trap.
4790
- const detachErrorBodyGuard = cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
4791
- const errorText = await upstreamResponse.text().catch(() => "unknown error");
4792
- detachErrorBodyGuard();
4793
- cleanupUpstreamAbort();
5366
+ let errorText: string;
5367
+ try {
5368
+ errorText = await readDisplaySafeErrorText(
5369
+ upstreamResponse,
5370
+ upstream.signal,
5371
+ "unknown error",
5372
+ );
5373
+ } finally {
5374
+ cleanupUpstreamAbort();
5375
+ }
4794
5376
  if (!isFixedCodexAccount(authCtx)) {
4795
5377
  recordSubagentQuotaFailureForThreadSpawn(
4796
5378
  req.headers,
@@ -4805,29 +5387,41 @@ async function handleResponsesInner(
4805
5387
  // Upstreams occasionally echo request details in error bodies — scrub token-shaped
4806
5388
  // material before it reaches the client-facing error surface.
4807
5389
  const upstreamRetryAfter = upstreamResponse.headers.get("retry-after");
4808
- const message = enrichOpenCodeZenRateLimitMessage(
4809
- `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`,
4810
- {
5390
+ const normalized = normalizeUpstreamErrorText(errorText, "unknown error");
5391
+ const message = normalized.cyberPolicy
5392
+ ? normalized.message
5393
+ ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText)
5394
+ : enrichOpenCodeZenRateLimitMessage(
5395
+ `Provider error ${upstreamResponse.status}: ${normalized.safeText}`,
5396
+ {
5397
+ status: upstreamResponse.status,
5398
+ providerName: route.providerName,
5399
+ baseUrl: route.provider.baseUrl,
5400
+ adapter: route.provider.adapter,
5401
+ authMode: route.provider.authMode,
5402
+ hasApiKey: Boolean(route.provider.apiKey?.trim()),
5403
+ upstreamRetryAfter,
5404
+ // This recovery path is the HTTP Responses wire; custom runTurn transports
5405
+ // never reach enrichOpenCodeZenRateLimitMessage here.
5406
+ supportsHttpSameKeyRetry: true,
5407
+ },
5408
+ );
5409
+ const retryAfter = normalized.cyberPolicy
5410
+ ? undefined
5411
+ : resolveClientRetryAfter({
4811
5412
  status: upstreamResponse.status,
4812
- providerName: route.providerName,
4813
- baseUrl: route.provider.baseUrl,
4814
- adapter: route.provider.adapter,
4815
- authMode: route.provider.authMode,
4816
- hasApiKey: Boolean(route.provider.apiKey?.trim()),
5413
+ message,
4817
5414
  upstreamRetryAfter,
4818
- // This recovery path is the HTTP Responses wire; custom runTurn transports
4819
- // never reach enrichOpenCodeZenRateLimitMessage here.
4820
- supportsHttpSameKeyRetry: true,
5415
+ });
5416
+ return formatErrorResponse(
5417
+ upstreamResponse.status,
5418
+ normalized.cyberPolicy ? (normalized.type ?? CYBER_POLICY_ERROR_CODE) : "upstream_error",
5419
+ message,
5420
+ {
5421
+ ...(normalized.cyberPolicy ? { code: CYBER_POLICY_ERROR_CODE } : {}),
5422
+ ...(retryAfter !== undefined ? { retryAfter } : {}),
4821
5423
  },
4822
5424
  );
4823
- const retryAfter = resolveClientRetryAfter({
4824
- status: upstreamResponse.status,
4825
- message,
4826
- upstreamRetryAfter,
4827
- });
4828
- return formatErrorResponse(upstreamResponse.status, "upstream_error", message, {
4829
- ...(retryAfter !== undefined ? { retryAfter } : {}),
4830
- });
4831
5425
  }
4832
5426
  }
4833
5427
 
@@ -5081,16 +5675,22 @@ async function handleResponsesInner(
5081
5675
  }
5082
5676
 
5083
5677
  if (!response.ok) {
5084
- // Same pre-read guard as the initial response's error branch: a non-2xx continuation body
5085
- // is still a Bun fetch body, and an abort landing before `.text()` attaches its reader
5086
- // orphans the internal rejection.
5087
- const detachContinuationErrorGuard = cancelBodyOnAbort(response.body, upstream.signal);
5088
- const errorText = await response.text().catch(() => "unknown error");
5089
- detachContinuationErrorGuard();
5678
+ const errorText = await readDisplaySafeErrorText(response, upstream.signal, "unknown error");
5679
+ const normalized = normalizeUpstreamErrorText(errorText, "unknown error");
5090
5680
  yield {
5091
5681
  type: "error",
5092
- status: response.status,
5093
- message: `Provider continuation error ${response.status}: ${redactSecretString(errorText.slice(0, 500))}`,
5682
+ status: normalized.cyberPolicy ? 400 : response.status,
5683
+ message: normalized.cyberPolicy
5684
+ ? normalized.message
5685
+ ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText)
5686
+ : `Provider continuation error ${response.status}: ${normalized.safeText}`,
5687
+ ...(normalized.cyberPolicy
5688
+ ? {
5689
+ errorType: normalized.type ?? CYBER_POLICY_ERROR_CODE,
5690
+ code: CYBER_POLICY_ERROR_CODE,
5691
+ retryable: false,
5692
+ }
5693
+ : {}),
5094
5694
  };
5095
5695
  return;
5096
5696
  }
@@ -5184,6 +5784,7 @@ async function handleResponsesInner(
5184
5784
  },
5185
5785
  onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
5186
5786
  commitReasoningReplayServingRoute();
5787
+ rememberKiroDeliveredFinalAnswer(activeAdapter.name, response);
5187
5788
  // Compaction turns must NOT enter the continuation cache: _rawBody still holds the full
5188
5789
  // PRE-compaction history, and a later previous_response_id expansion would rehydrate the
5189
5790
  // giant stale chain Codex just replaced.
@@ -5261,6 +5862,7 @@ async function handleResponsesInner(
5261
5862
  });
5262
5863
  // See the streaming branch: compaction turns skip the continuation cache.
5263
5864
  if (!routedCompaction) {
5865
+ rememberKiroDeliveredFinalAnswer(activeAdapter.name, json);
5264
5866
  rememberResponseState(
5265
5867
  parsed._rawBody,
5266
5868
  json,