@bitkyc08/opencodex 2.33.0 → 2.34.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 (159) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/index-C4TMRloX.js +112 -0
  3. package/gui/dist/assets/index-DQ-Ie18T.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +40 -3
  8. package/src/adapters/cursor/call-id.ts +44 -0
  9. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  10. package/src/adapters/cursor/discovery.ts +60 -2
  11. package/src/adapters/cursor/effort-map.ts +79 -1
  12. package/src/adapters/cursor/envelope-echo.ts +162 -0
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +14 -0
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  17. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  18. package/src/adapters/cursor/native-exec-network.ts +1 -1
  19. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  20. package/src/adapters/cursor/native-exec.ts +20 -0
  21. package/src/adapters/cursor/protobuf-request.ts +68 -14
  22. package/src/adapters/cursor/request-builder.ts +29 -6
  23. package/src/adapters/cursor/tool-definitions.ts +11 -2
  24. package/src/adapters/cursor/tool-result-normalize.ts +32 -1
  25. package/src/adapters/cursor/types.ts +20 -0
  26. package/src/adapters/cursor.ts +189 -37
  27. package/src/adapters/google-antigravity-replay.ts +102 -23
  28. package/src/adapters/google-errors.ts +44 -12
  29. package/src/adapters/google.ts +115 -16
  30. package/src/adapters/image.ts +1 -1
  31. package/src/adapters/kiro-constants.ts +15 -0
  32. package/src/adapters/kiro-tools.ts +43 -15
  33. package/src/adapters/kiro.ts +54 -9
  34. package/src/adapters/openai-chat.ts +286 -242
  35. package/src/adapters/openai-responses.ts +334 -24
  36. package/src/adapters/tool-catalog-nudge.ts +2 -2
  37. package/src/adapters/xai-tool-schema.ts +436 -0
  38. package/src/bridge.ts +67 -24
  39. package/src/chat/inbound.ts +14 -1
  40. package/src/chat/outbound.ts +15 -7
  41. package/src/claude/agents-inject.ts +8 -1
  42. package/src/claude/outbound.ts +10 -8
  43. package/src/cli/account-api.ts +32 -4
  44. package/src/cli/account-extended.ts +1 -1
  45. package/src/cli/account.ts +29 -5
  46. package/src/cli/alias.ts +66 -0
  47. package/src/cli/claude.ts +26 -1
  48. package/src/cli/dispatch.ts +13 -1
  49. package/src/cli/help.ts +1 -0
  50. package/src/cli/init.ts +1 -0
  51. package/src/cli/models-runtime.ts +95 -0
  52. package/src/cli/models.ts +13 -7
  53. package/src/cli/provider-runtime.ts +16 -2
  54. package/src/cli/registry.ts +6 -1
  55. package/src/cli/v2.ts +34 -10
  56. package/src/codex/account-pause.ts +2 -1
  57. package/src/codex/account-priority.ts +3 -2
  58. package/src/codex/app-server-processes.ts +80 -6
  59. package/src/codex/auth-api.ts +46 -8
  60. package/src/codex/auth-context.ts +21 -18
  61. package/src/codex/catalog/aggregation.ts +6 -0
  62. package/src/codex/catalog/native-models.ts +5 -2
  63. package/src/codex/catalog/parsing.ts +16 -0
  64. package/src/codex/catalog/provider-fetch.ts +20 -3
  65. package/src/codex/catalog/sync.ts +127 -2
  66. package/src/codex/catalog.ts +1 -1
  67. package/src/codex/codex-write-lock.ts +3 -1
  68. package/src/codex/convergence-types.ts +1 -1
  69. package/src/codex/convergence.ts +22 -2
  70. package/src/codex/desired-state.ts +2 -2
  71. package/src/codex/desktop-app-restart.ts +18 -5
  72. package/src/codex/inject-coordination.ts +83 -0
  73. package/src/codex/inject.ts +14 -1
  74. package/src/codex/log-guard/inspect.ts +22 -4
  75. package/src/codex/model-entitlements.ts +9 -2
  76. package/src/codex/prompt-layers.ts +371 -25
  77. package/src/codex/prompt-text-probe.ts +238 -0
  78. package/src/codex/quota.ts +91 -10
  79. package/src/codex/routing.ts +9 -0
  80. package/src/codex/subagent-model-fallback.ts +104 -25
  81. package/src/codex/transition-state.ts +107 -8
  82. package/src/combos/types.ts +10 -0
  83. package/src/compatibility/openai-responses.ts +33 -1
  84. package/src/config/rebase-provenance.ts +68 -0
  85. package/src/config.ts +107 -9
  86. package/src/generated/compatibility-version.json +191 -139
  87. package/src/images/loop.ts +5 -4
  88. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  89. package/src/lab/fabric/producer-child.ts +1 -1
  90. package/src/lib/config-ownership.ts +20 -0
  91. package/src/lib/errors.ts +11 -2
  92. package/src/lib/package-tree-integrity.ts +101 -0
  93. package/src/oauth/generic-account-failover.ts +231 -0
  94. package/src/oauth/index.ts +57 -5
  95. package/src/oauth/key-providers.ts +10 -1
  96. package/src/oauth/kiro.ts +45 -0
  97. package/src/oauth/types.ts +15 -0
  98. package/src/providers/command-code-efforts.ts +38 -6
  99. package/src/providers/context-cap.ts +4 -3
  100. package/src/providers/default-aliases.ts +65 -0
  101. package/src/providers/derive.ts +28 -0
  102. package/src/providers/fastwire.ts +7 -1
  103. package/src/providers/model-presets.ts +119 -0
  104. package/src/providers/new-model-policy.ts +146 -0
  105. package/src/providers/provider-id-rewrite.ts +2 -1
  106. package/src/providers/quota.ts +65 -7
  107. package/src/providers/registry.ts +160 -68
  108. package/src/providers/slug-codec.ts +52 -0
  109. package/src/responses/code-mode-helper-compat.ts +50 -0
  110. package/src/responses/custom-tool-compat.ts +34 -10
  111. package/src/responses/parser.ts +4 -0
  112. package/src/responses/schema.ts +5 -1
  113. package/src/responses/thought-signature-replay.ts +17 -0
  114. package/src/router.ts +40 -2
  115. package/src/routing/analytics.ts +1 -0
  116. package/src/routing/quota.ts +10 -0
  117. package/src/server/auth-cors.ts +4 -0
  118. package/src/server/chat-completions.ts +24 -16
  119. package/src/server/chat-native-sse.ts +3 -3
  120. package/src/server/chat-native.ts +30 -11
  121. package/src/server/claude-messages.ts +1 -1
  122. package/src/server/index.ts +77 -11
  123. package/src/server/lifecycle.ts +52 -1
  124. package/src/server/management/agent-settings-routes.ts +31 -15
  125. package/src/server/management/codex-prompt-routes.ts +570 -0
  126. package/src/server/management/combo-routes.ts +2 -1
  127. package/src/server/management/config-routes.ts +27 -9
  128. package/src/server/management/context.ts +9 -0
  129. package/src/server/management/model-routes.ts +266 -0
  130. package/src/server/management/provider-routes.ts +6 -0
  131. package/src/server/management/routing-profile-routes.ts +2 -2
  132. package/src/server/management-api.ts +2 -0
  133. package/src/server/relay-eager.ts +147 -20
  134. package/src/server/relay.ts +251 -19
  135. package/src/server/request-log-conversation.ts +21 -0
  136. package/src/server/request-log.ts +41 -15
  137. package/src/server/responses/collaboration.ts +37 -3
  138. package/src/server/responses/combo-stream-preflight.ts +10 -3
  139. package/src/server/responses/core.ts +494 -114
  140. package/src/server/responses/empty-completion-guard.ts +35 -0
  141. package/src/server/responses/fetch-helpers.ts +2 -1
  142. package/src/server/responses/input-admission.ts +3 -1
  143. package/src/server/responses/passthrough-error.ts +33 -9
  144. package/src/server/responses/responses-field-backfill.ts +105 -13
  145. package/src/server/responses-custom-tool-repair.ts +52 -7
  146. package/src/server/responses-terminal-repair.ts +25 -4
  147. package/src/server/sse-frame-buffer.ts +31 -4
  148. package/src/server/ws-bridge.ts +14 -2
  149. package/src/types/config.ts +53 -0
  150. package/src/types/provider.ts +64 -0
  151. package/src/types/request.ts +11 -3
  152. package/src/types/tools.ts +7 -5
  153. package/src/types.ts +1 -0
  154. package/src/usage/expected-prices.ts +43 -24
  155. package/src/usage/log.ts +3 -0
  156. package/src/web-search/exa-executor.ts +40 -9
  157. package/src/web-search/loop.ts +5 -4
  158. package/gui/dist/assets/index-23-Lf7jR.js +0 -102
  159. package/gui/dist/assets/index-DxJMDyOr.css +0 -1
@@ -30,6 +30,7 @@ import {
30
30
  import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay";
31
31
  import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
32
32
  import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../../adapters/openai-responses";
33
+ import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema";
33
34
  import {
34
35
  copyPreviousResponseReplayProvenance,
35
36
  expandPreviousResponseInput,
@@ -71,6 +72,13 @@ import {
71
72
  targetKey,
72
73
  } from "../../combos";
73
74
  import { isInjectionDebugEnabled } from "../../lib/debug-settings";
75
+ import {
76
+ CYBER_POLICY_ERROR_CODE,
77
+ CYBER_POLICY_FALLBACK_MESSAGE,
78
+ adapterFailureFromMessage,
79
+ isCyberPolicyCode,
80
+ isCyberPolicyMessage,
81
+ } from "../../lib/errors";
74
82
  import { injectionDebugLog } from "../../lib/injection-debug-log";
75
83
  import { resolveClientRetryAfter } from "../../lib/retry-after";
76
84
  import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit";
@@ -107,6 +115,13 @@ import {
107
115
  resolveAnthropicAccountForSession,
108
116
  rotateAnthropicAccountOn429,
109
117
  } from "../../oauth/anthropic-routing";
118
+ import {
119
+ failoverAccountSnapshot,
120
+ GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
121
+ isGenericFailoverProvider,
122
+ isGenericOAuthFailoverEnabled,
123
+ rotateGenericOAuthAccountOn429,
124
+ } from "../../oauth/generic-account-failover";
110
125
  import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
111
126
  import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
112
127
  import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
@@ -135,6 +150,7 @@ import {
135
150
  resolveCodexModelEntitlements,
136
151
  } from "../../codex/model-entitlements";
137
152
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
153
+ import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account";
138
154
  import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug";
139
155
  import {
140
156
  computeQuotaCooldown,
@@ -183,7 +199,7 @@ import {
183
199
  providerModelResponsesUpstreamStreaming,
184
200
  type InboundWire,
185
201
  } from "../../providers/registry";
186
- import type { AdapterRequest } from "../../adapters/base";
202
+ import type { AdapterRequest, ProviderAdapter } from "../../adapters/base";
187
203
  import {
188
204
  hasKeyPoolFailover,
189
205
  rateLimitRetryDelayMs,
@@ -203,6 +219,10 @@ import {
203
219
  applySubagentModelFallback,
204
220
  maybePrimeSubagentQuota,
205
221
  recordSubagentQuotaFailureForThreadSpawn,
222
+ resolveSubagentFallbackChain,
223
+ subagentFallbackNeedsModelEntitlements,
224
+ type SubagentModelEligibleAccountIds,
225
+ type SubagentPoolAccountPreview,
206
226
  } from "../../codex/subagent-model-fallback";
207
227
  import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
208
228
  import {
@@ -295,7 +315,7 @@ import {
295
315
  payloadRewriteAsBlockRewrite,
296
316
  relaySseWithBlockRewrite,
297
317
  } from "../sse-payload-rewrite";
298
- import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat";
318
+ import { restoreRoutedCustomCalls, restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat";
299
319
  import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair";
300
320
  import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat";
301
321
  import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair";
@@ -322,6 +342,7 @@ import { responsesJsonToSseStream } from "../responses-json-events";
322
342
  import { guardTerminalEventStream } from "./terminal-guard";
323
343
  import {
324
344
  emptyCompletionRetryEnabled,
345
+ observeEmptyCompletion,
325
346
  guardEmptyCompletionEventStream,
326
347
  } from "./empty-completion-guard";
327
348
  import { preflightComboStreamResponse } from "./combo-stream-preflight";
@@ -662,6 +683,48 @@ export async function readDisplaySafeErrorText(
662
683
  }
663
684
  }
664
685
 
686
+ interface NormalizedUpstreamErrorText {
687
+ safeText: string;
688
+ message?: string;
689
+ type?: string;
690
+ code?: string;
691
+ cyberPolicy: boolean;
692
+ }
693
+
694
+ /**
695
+ * Extract the structured provider error envelope without making `error.type` authoritative.
696
+ * Policy identity comes from the dedicated code (or the legacy message fallback); a credible
697
+ * upstream type is only carried through so callers do not erase provider diagnostics.
698
+ */
699
+ function normalizeUpstreamErrorText(text: string, fallback: string): NormalizedUpstreamErrorText {
700
+ const safeText = redactSecretString(text).slice(0, 500).trim() || fallback;
701
+ let message: string | undefined;
702
+ let type: string | undefined;
703
+ let code: string | undefined;
704
+ try {
705
+ const parsed = JSON.parse(text) as Record<string, unknown>;
706
+ const response = parsed.response && typeof parsed.response === "object" && !Array.isArray(parsed.response)
707
+ ? parsed.response as Record<string, unknown>
708
+ : undefined;
709
+ const candidates = [parsed.error, response?.error, response?.last_error, parsed.last_error, parsed];
710
+ const source = candidates.find((candidate): candidate is Record<string, unknown> => {
711
+ if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) return false;
712
+ const record = candidate as Record<string, unknown>;
713
+ return [record.message, record.type, record.code].some(value => typeof value === "string");
714
+ });
715
+ if (!source) return { safeText, cyberPolicy: isCyberPolicyMessage(safeText) };
716
+ if (typeof source.message === "string" && source.message.trim()) {
717
+ message = redactSecretString(source.message.trim()).slice(0, 500);
718
+ }
719
+ if (typeof source.type === "string" && source.type.trim()) type = source.type.trim();
720
+ if (typeof source.code === "string" && source.code.trim()) code = source.code.trim();
721
+ } catch {
722
+ /* non-JSON upstream body — retain the bounded display-safe text */
723
+ }
724
+ const cyberPolicy = isCyberPolicyCode(code) || isCyberPolicyMessage(message ?? safeText);
725
+ return { safeText, message, type, code, cyberPolicy };
726
+ }
727
+
665
728
  function prepareOpaqueBlobRecovery(parsed: OcxParsedRequest): void {
666
729
  parsed._stripReasoningEncryptedContent = true;
667
730
  }
@@ -873,11 +936,15 @@ export function codexAccountGatedCanonicalWireModel(modelId: string): string | u
873
936
  return undefined;
874
937
  }
875
938
 
876
- function applyCodexAccountGatedWireNormalization(parsed: OcxParsedRequest, route: RouteResult): void {
939
+ function applyCodexAccountGatedWireNormalization(parsed: OcxParsedRequest, route: RouteResult, logCtx?: RequestLogContext): void {
877
940
  if (!isCanonicalOpenAiForwardProvider(route.provider)) return;
878
941
  const wireModel = codexAccountGatedCanonicalWireModel(route.modelId);
879
942
  if (!wireModel) return;
880
943
 
944
+ if (logCtx) {
945
+ logCtx.preserveResolvedModelFromRoute = true;
946
+ delete logCtx.resolvedModel;
947
+ }
881
948
  parsed.modelId = wireModel;
882
949
  if (!parsed._rawBody || typeof parsed._rawBody !== "object") return;
883
950
  const raw = parsed._rawBody as Record<string, unknown>;
@@ -1229,6 +1296,8 @@ export interface HandleResponsesOptions {
1229
1296
  /** One-shot TTFT callback: first non-empty model output observed (WP4). */
1230
1297
  onFirstOutput?: () => void;
1231
1298
  onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void;
1299
+ /** Internal deterministic seam for account-gated native fallback tests. */
1300
+ resolveCodexModelEntitlements?: typeof resolveCodexModelEntitlements;
1232
1301
  recordTerminalOutcomes?: boolean;
1233
1302
  setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void;
1234
1303
  onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
@@ -1312,27 +1381,30 @@ export async function consumeComboFailure(
1312
1381
  let classificationText = fallback;
1313
1382
  let usage: OcxUsage | undefined;
1314
1383
  let upstreamCode: string | undefined;
1384
+ let upstreamMessage: string | undefined;
1385
+ let upstreamType: string | undefined;
1315
1386
  try {
1316
1387
  const body = await readBoundedResponseBody(response, { signal });
1317
1388
  usage = usageFromComboFailureText(body.text);
1318
1389
  if (body.displaySafe) {
1319
- const safeText = redactSecretString(body.text).slice(0, 500);
1320
- if (safeText) classificationText = safeText;
1321
- try {
1322
- const parsed = JSON.parse(body.text) as { error?: { code?: unknown } | string };
1323
- const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.code : undefined;
1324
- if (typeof nested === "string" && nested.length > 0) upstreamCode = nested;
1325
- } catch {
1326
- /* non-JSON upstream body — message-only classification */
1327
- }
1390
+ const normalized = normalizeUpstreamErrorText(body.text, fallback);
1391
+ classificationText = normalized.safeText;
1392
+ upstreamCode = normalized.code;
1393
+ upstreamMessage = normalized.message;
1394
+ upstreamType = normalized.type;
1328
1395
  }
1329
1396
  } catch (error) {
1330
1397
  if (signal?.aborted) throw error;
1331
1398
  classificationText = fallback;
1332
1399
  }
1333
- const message = classificationText === fallback
1334
- ? fallback
1335
- : `${fallback}: ${classificationText}`;
1400
+ const cyberFailure = isCyberPolicyCode(upstreamCode) || isCyberPolicyMessage(classificationText);
1401
+ const normalizedUpstreamCode = cyberFailure ? CYBER_POLICY_ERROR_CODE : upstreamCode;
1402
+ const message = cyberFailure
1403
+ ? upstreamMessage
1404
+ ?? (isCyberPolicyCode(upstreamCode) ? CYBER_POLICY_FALLBACK_MESSAGE : classificationText)
1405
+ : classificationText === fallback
1406
+ ? fallback
1407
+ : `${fallback}: ${classificationText}`;
1336
1408
  const upstreamRetryAfter = response.headers.get("retry-after");
1337
1409
  // Client response may get the synthetic "2" fallback; cooldown metadata must not —
1338
1410
  // otherwise coolComboTarget treats it as a 2s cooldown instead of the 60s default.
@@ -1350,13 +1422,18 @@ export async function consumeComboFailure(
1350
1422
  includeDefault: false,
1351
1423
  });
1352
1424
  return {
1353
- response: formatErrorResponse(response.status, "upstream_error", message, {
1354
- ...(upstreamCode !== undefined ? { code: upstreamCode } : {}),
1355
- ...(clientRetryAfter !== undefined ? { retryAfter: clientRetryAfter } : {}),
1356
- }),
1425
+ response: formatErrorResponse(
1426
+ response.status,
1427
+ cyberFailure ? (upstreamType ?? CYBER_POLICY_ERROR_CODE) : "upstream_error",
1428
+ message,
1429
+ {
1430
+ ...(normalizedUpstreamCode !== undefined ? { code: normalizedUpstreamCode } : {}),
1431
+ ...(clientRetryAfter !== undefined ? { retryAfter: clientRetryAfter } : {}),
1432
+ },
1433
+ ),
1357
1434
  classificationText,
1358
- ...(upstreamCode !== undefined ? { upstreamCode } : {}),
1359
- ...(cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}),
1435
+ ...(normalizedUpstreamCode !== undefined ? { upstreamCode: normalizedUpstreamCode } : {}),
1436
+ ...(!cyberFailure && cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}),
1360
1437
  ...(usage ? { usage } : {}),
1361
1438
  };
1362
1439
  }
@@ -1520,6 +1597,7 @@ async function resolveResponsesCodexAuth(
1520
1597
  modelId: route.modelId,
1521
1598
  substituteMainCredentialForDirect: substituteMainCredential,
1522
1599
  beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
1600
+ resolveCodexModelEntitlements: options.resolveCodexModelEntitlements,
1523
1601
  });
1524
1602
  options.onCodexAuthContextResolved?.(authCtx);
1525
1603
  } else {
@@ -1558,6 +1636,25 @@ async function resolveResponsesCodexAuth(
1558
1636
  }
1559
1637
  }
1560
1638
 
1639
+ async function resolveSubagentFallbackModelEligibility(args: {
1640
+ config: OcxConfig;
1641
+ fallbackChain: readonly string[] | null;
1642
+ nativeMainReadsForbidden: boolean;
1643
+ resolver: typeof resolveCodexModelEntitlements;
1644
+ }): Promise<SubagentModelEligibleAccountIds | undefined> {
1645
+ if (!subagentFallbackNeedsModelEntitlements(args.fallbackChain, args.config)) return undefined;
1646
+ const excludeAccountIds = args.nativeMainReadsForbidden
1647
+ ? new Set([MAIN_CODEX_ACCOUNT_ID])
1648
+ : undefined;
1649
+ const snapshot = await args.resolver(args.config, { excludeAccountIds });
1650
+ return (modelId) => {
1651
+ const entitledAccountIds = entitledCodexAccountIdsForModel(snapshot, modelId);
1652
+ return entitledAccountIds
1653
+ ? new Set([...entitledAccountIds].filter(accountId => !excludeAccountIds?.has(accountId)))
1654
+ : undefined;
1655
+ };
1656
+ }
1657
+
1561
1658
  /**
1562
1659
  * Apply every route-dependent request mutation against the final selected route.
1563
1660
  * Must run only after subagent fallback has settled the model/provider.
@@ -1602,6 +1699,7 @@ async function applyFinalRouteRequestNormalization(args: {
1602
1699
  logCtx.provider = route.providerName;
1603
1700
  logCtx.providerAdapter = route.provider.adapter;
1604
1701
  logCtx.routeDecision = route.routeDecision;
1702
+ if (route.routeReason === "model-alias" || route.modelId !== responseModelId && responseModelId.includes("/")) logCtx.requestedAlias = responseModelId;
1605
1703
 
1606
1704
  if (responsesUpstreamStreaming === false && route.provider.adapter === "openai-responses") {
1607
1705
  parsed.stream = false;
@@ -1644,7 +1742,15 @@ async function applyFinalRouteRequestNormalization(args: {
1644
1742
  );
1645
1743
  const modelServiceTierSupport = serviceTierSupportFromPolicy(fastPolicy);
1646
1744
  const callerTier = parsed.options.serviceTier;
1647
- parsed.options.tierObservation = tierObservationContext(fastPolicy, config.fastMode, callerTier);
1745
+ // The ChatGPT-internal Codex backend echoes `service_tier: "default"` even on turns it
1746
+ // scheduled as priority, so its echo cannot confirm OR deny Fast. Believing it reported every
1747
+ // Fast request as `response-declined` (#2558). The public API's echo stays authoritative.
1748
+ parsed.options.tierObservation = tierObservationContext(
1749
+ fastPolicy,
1750
+ config.fastMode,
1751
+ callerTier,
1752
+ isCanonicalOpenAiForwardProvider(route.provider) ? false : undefined,
1753
+ );
1648
1754
  parsed.options.tierDecision = decideTier(fastPolicy, config.fastMode, callerTier);
1649
1755
  parsed.options.serviceTier = tierValueAfterDecision(parsed.options.tierDecision, callerTier);
1650
1756
  if (fastPolicy.capability === true && fastPolicy.fastWire === null) {
@@ -2184,6 +2290,7 @@ async function handleResponsesInner(
2184
2290
  (body as { input?: unknown } | undefined)?.input,
2185
2291
  );
2186
2292
  const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
2293
+ const cursorClientThreadId = codexPoolAffinityKey(req.headers);
2187
2294
  const originalBody = body;
2188
2295
  if (options.comboReplaySnapshot) {
2189
2296
  copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body);
@@ -2253,6 +2360,7 @@ async function handleResponsesInner(
2253
2360
  parsed._reasoningReplayScope = { clientThreadId: normalizedCacheKey };
2254
2361
  }
2255
2362
  }
2363
+ if (cursorClientThreadId) parsed._cursorClientThreadId = cursorClientThreadId;
2256
2364
  } catch (err) {
2257
2365
  if (isTranslatorBudgetExceededError(err)) {
2258
2366
  return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", {
@@ -2354,7 +2462,12 @@ async function handleResponsesInner(
2354
2462
  // also fail closed without polling quota upstream. Cached fallback state can still select a
2355
2463
  // provider with native continuation support below.
2356
2464
  const threadSpawn = isThreadSpawnRequest(req.headers);
2357
- const previewSelectionAdmission = threadSpawn && route.codexAccountId === undefined
2465
+ const initialSubagentFallbackChain = threadSpawn && !options.comboAttempt
2466
+ ? resolveSubagentFallbackChain(parsed, config)
2467
+ : null;
2468
+ const previewSelectionAdmission = threadSpawn
2469
+ && !options.comboAttempt
2470
+ && (route.codexAccountId === undefined || initialSubagentFallbackChain !== null)
2358
2471
  ? codexAccountSelectionForTurn(options.turnAdmissionLease)?.()
2359
2472
  : undefined;
2360
2473
  const nativeMainRecoveryBlocked = isNativeMainTrafficBlocked();
@@ -2366,7 +2479,8 @@ async function handleResponsesInner(
2366
2479
  };
2367
2480
  let selectedForwardHeaders = req.headers;
2368
2481
  let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
2369
- let subagentFallbackPreviewAccountId: string | null | undefined;
2482
+ let subagentFallbackAccountPreview: SubagentPoolAccountPreview | undefined;
2483
+ let subagentFallbackModelEligibleAccountIdsForModel: SubagentModelEligibleAccountIds | undefined;
2370
2484
  let subagentQuotaFailureModel = parsed.modelId;
2371
2485
  const parentThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() ?? null;
2372
2486
  const poolAffinityKey = codexPoolAffinityKey(req.headers) ?? null;
@@ -2384,28 +2498,47 @@ async function handleResponsesInner(
2384
2498
  // normalization (virtual models, effort caps, service tier, wire protocol).
2385
2499
  // Preview the preferred Codex account without acquiring a probe lease or refreshing
2386
2500
  // tokens — auth is resolved only after the final route is selected.
2387
- if (threadSpawn && !options.comboAttempt && route.codexAccountId === undefined) {
2501
+ if (
2502
+ threadSpawn
2503
+ && !options.comboAttempt
2504
+ && (route.codexAccountId === undefined || initialSubagentFallbackChain !== null)
2505
+ ) {
2388
2506
  // The final resolveCodexAuthContext binds under codexQuotaScopeForModel(route.modelId),
2389
2507
  // so the preview must read the same scope slot — an undefined scope would map to the
2390
2508
  // "legacy" affinity bucket and never find a binding made under "shared" or a native
2391
2509
  // model scope, making the preview diverge from the account that actually authenticates.
2392
- const previewAccountId = previewCodexAccountForRequest(
2510
+ const fallbackChain = initialSubagentFallbackChain;
2511
+ subagentFallbackModelEligibleAccountIdsForModel = await resolveSubagentFallbackModelEligibility({
2512
+ config,
2513
+ fallbackChain,
2514
+ nativeMainReadsForbidden,
2515
+ resolver: options.resolveCodexModelEntitlements ?? resolveCodexModelEntitlements,
2516
+ });
2517
+ const fallbackNow = Date.now();
2518
+ subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest(
2393
2519
  poolAffinityKey,
2394
2520
  config,
2395
- Date.now(),
2396
- codexQuotaScopeForModel(route.modelId),
2397
- previewSelectionOptions,
2521
+ previewNow,
2522
+ codexQuotaScopeForModel(modelId),
2523
+ { ...previewSelectionOptions, modelEligibleAccountIds },
2524
+ );
2525
+ const previewAccountId = route.codexAccountId ?? subagentFallbackAccountPreview(
2526
+ route.modelId,
2527
+ fallbackNow,
2528
+ subagentFallbackModelEligibleAccountIdsForModel?.(route.modelId),
2398
2529
  );
2399
- subagentFallbackPreviewAccountId = previewAccountId;
2400
2530
  subagentFallbackAccountId = previewAccountId ?? config.activeCodexAccountId ?? null;
2401
2531
  const fallback = applySubagentModelFallback(
2402
2532
  parsed,
2403
2533
  req.headers,
2404
2534
  config,
2405
2535
  previewAccountId,
2406
- Date.now(),
2536
+ fallbackNow,
2407
2537
  unreadableEncryptedAgentTask,
2408
2538
  previewSelectionOptions,
2539
+ subagentFallbackAccountPreview,
2540
+ subagentFallbackModelEligibleAccountIdsForModel,
2541
+ fallbackChain,
2409
2542
  );
2410
2543
  if (fallback) {
2411
2544
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
@@ -2471,6 +2604,7 @@ async function handleResponsesInner(
2471
2604
  "_providerContinuationOwner",
2472
2605
  "_cursorConversationId",
2473
2606
  "_clientThreadId",
2607
+ "_cursorClientThreadId",
2474
2608
  "_reasoningReplayScope",
2475
2609
  "_cursorIsolateConversation",
2476
2610
  ];
@@ -2488,15 +2622,47 @@ async function handleResponsesInner(
2488
2622
  // The ciphertext-only pass intentionally excludes routed candidates. Once recovery
2489
2623
  // makes the assignment readable, run selection again with the full configured chain
2490
2624
  // and keep the route in sync with any newly selected fallback.
2491
- const fallback = applySubagentModelFallback(
2492
- parsed,
2493
- req.headers,
2494
- config,
2495
- subagentFallbackPreviewAccountId,
2496
- Date.now(),
2497
- false,
2498
- previewSelectionOptions,
2499
- );
2625
+ const recoverySelectionAdmission = codexAccountSelectionForTurn(options.turnAdmissionLease)?.();
2626
+ const fallback = (() => {
2627
+ try {
2628
+ const recoveryNativeMainBlocked = isNativeMainTrafficBlocked();
2629
+ const recoverySelectionOptions = {
2630
+ nativeMainSelectionOnly: !recoveryNativeMainBlocked
2631
+ && recoverySelectionAdmission?.mainProfileDraining === true,
2632
+ };
2633
+ const recoveryNow = Date.now();
2634
+ // Carry the entitlement filter through recovery too (#2509/#2623). The scope was
2635
+ // already re-previewed per candidate here; the ELIGIBLE-ACCOUNT set was not, so a
2636
+ // recovered assignment could select an account that is not entitled to the model
2637
+ // and then fail closed at final auth — the same class of stale-selection bug as
2638
+ // the quota scope, one layer over.
2639
+ subagentFallbackAccountPreview = (modelId, previewNow, modelEligibleAccountIds) => previewCodexAccountForRequest(
2640
+ poolAffinityKey,
2641
+ config,
2642
+ previewNow,
2643
+ codexQuotaScopeForModel(modelId),
2644
+ { ...recoverySelectionOptions, modelEligibleAccountIds },
2645
+ );
2646
+ const recoveryPreviewAccountId = subagentFallbackAccountPreview(
2647
+ parsed.modelId,
2648
+ recoveryNow,
2649
+ subagentFallbackModelEligibleAccountIdsForModel?.(parsed.modelId),
2650
+ );
2651
+ return applySubagentModelFallback(
2652
+ parsed,
2653
+ req.headers,
2654
+ config,
2655
+ recoveryPreviewAccountId,
2656
+ recoveryNow,
2657
+ false,
2658
+ recoverySelectionOptions,
2659
+ subagentFallbackAccountPreview,
2660
+ subagentFallbackModelEligibleAccountIdsForModel,
2661
+ );
2662
+ } finally {
2663
+ recoverySelectionAdmission?.release();
2664
+ }
2665
+ })();
2500
2666
  if (fallback) {
2501
2667
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
2502
2668
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
@@ -2619,7 +2785,7 @@ async function handleResponsesInner(
2619
2785
  }
2620
2786
 
2621
2787
  route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
2622
- applyCodexAccountGatedWireNormalization(parsed, route);
2788
+ applyCodexAccountGatedWireNormalization(parsed, route, logCtx);
2623
2789
  logCtx.provider = route.codexAccountNamespace
2624
2790
  ? `${route.providerName}-${route.codexAccountNamespace}`
2625
2791
  : formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
@@ -2641,6 +2807,42 @@ async function handleResponsesInner(
2641
2807
  let replayOAuthCredentialSnapshot: Pick<OAuthAccessSnapshot, "accountId" | "generation"> | undefined;
2642
2808
  let anthropicPoolAccountId: string | null = null;
2643
2809
  let anthropicPoolFailovers = 0;
2810
+ // Generic OAuth rotation (#2568) for providers with no pool of their own. Bound to the account
2811
+ // the request actually used, so a concurrent rotation cannot cool an innocent replacement.
2812
+ let genericFailoverAccountId: string | null = null;
2813
+ let genericFailovers = 0;
2814
+ /**
2815
+ * Apply a rotated account's FULL credential snapshot to the live route (#2568d).
2816
+ *
2817
+ * One helper for all three rotation sites on purpose. Each site used to inline the same four
2818
+ * lines, and the divergence that produced was the bug: `apiKey` was swapped while the routing
2819
+ * metadata paired with it stayed behind.
2820
+ *
2821
+ * Returns false when the snapshot cannot be used safely, and the caller must then abandon the
2822
+ * rotation rather than send a half-applied identity:
2823
+ *
2824
+ * - Copilot pins its bearer to an account-scoped regional origin, so transport is re-resolved
2825
+ * with the new account's `apiBaseUrl` instead of inheriting the previous account's host.
2826
+ * - A Cloud Code Assist provider needs an account-matched project. Antigravity's refresh path
2827
+ * tolerates project discovery failing, so a stored account can legitimately have no project;
2828
+ * sending that account's bearer with the FAILED account's project is worse than not rotating.
2829
+ */
2830
+ const applyFailoverSnapshot = (snapshot: OAuthAccessSnapshot): boolean => {
2831
+ if (route.provider.googleMode === "cloud-code-assist" && !snapshot.projectId) return false;
2832
+ let rotatedProvider: OcxProviderConfig = { ...route.provider, apiKey: snapshot.accessToken };
2833
+ if (route.providerName === "github-copilot") {
2834
+ rotatedProvider = resolveProviderTransport(
2835
+ route.providerName,
2836
+ rotatedProvider,
2837
+ parsed.options.promptCacheKey,
2838
+ snapshot.apiBaseUrl,
2839
+ ) as OcxProviderConfig;
2840
+ }
2841
+ if (snapshot.projectId) rotatedProvider = { ...rotatedProvider, project: snapshot.projectId };
2842
+ route.provider = rotatedProvider;
2843
+ if (route.providerName === "kiro") parsed._kiroAuthContext = { ...(snapshot.kiro ?? {}) };
2844
+ return true;
2845
+ };
2644
2846
  const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth"
2645
2847
  ? anthropicSessionKeyFromParts({
2646
2848
  sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
@@ -2680,6 +2882,11 @@ async function handleResponsesInner(
2680
2882
  };
2681
2883
  if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
2682
2884
  route.provider = { ...route.provider, apiKey: resolved.accessToken };
2885
+ // Remember which account actually served this request so a 429 cools THAT one, not
2886
+ // whichever account is active by the time the response comes back (#2568).
2887
+ if (isGenericFailoverProvider(route.providerName, route.provider)) {
2888
+ genericFailoverAccountId = resolved.accountId;
2889
+ }
2683
2890
  if (route.providerName === "kiro") {
2684
2891
  // `{}` is intentional: this is an account-scoped request with no stored routing metadata.
2685
2892
  // Only genuinely accountless adapter calls leave the context undefined and use local/env fallback.
@@ -2758,6 +2965,7 @@ async function handleResponsesInner(
2758
2965
  (logCtx.attempts ??= []).push(attempt);
2759
2966
  }
2760
2967
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name, logCtx.accountLogLabel);
2968
+ let runTurnAdapter = adapter;
2761
2969
  if (adapter.runTurn) {
2762
2970
  recordAdapterTierMetadata(logCtx, adapter.tierLogForRunTurn?.(parsed));
2763
2971
  }
@@ -2886,8 +3094,9 @@ async function handleResponsesInner(
2886
3094
  delete parsed.options.parallelToolCalls;
2887
3095
  // The compaction turn is a plain prose summary; a surviving structured-output format
2888
3096
  // would force schema-constrained JSON into the synthetic compaction item. The flag and
2889
- // the raw `text` controls go too: Kiro's capability guard reads both and would reject
2890
- // the turn outright, and the key-mode openai-responses adapter builds from _rawBody.
3097
+ // the raw `text` control go too: the key-mode openai-responses adapter builds from
3098
+ // _rawBody, so a surviving format there would still reach the upstream. (The Kiro
3099
+ // guard no longer reads _rawBody.text; it refuses structured output only.)
2891
3100
  delete parsed.options.textFormat;
2892
3101
  delete parsed._structuredOutput;
2893
3102
  if (parsed._rawBody && typeof parsed._rawBody === "object") {
@@ -2958,7 +3167,9 @@ async function handleResponsesInner(
2958
3167
  // the rotation-rebuild and bridged paths already answer 400 for the identical throw. Rethrowing
2959
3168
  // it here escaped every catch up to the Bun handler, so the same request produced an
2960
3169
  // unstructured 500 — and no request log — depending only on whether a rotation ran first.
2961
- if (error instanceof NamespaceToolCollisionError) {
3170
+ // Same shape for a tool_choice this proxy cannot honor: the destination rejects a schema the
3171
+ // catalog had to drop, so the selector naming it is a client input error, not a 500.
3172
+ if (error instanceof NamespaceToolCollisionError || error instanceof XaiToolSchemaCompatibilityError) {
2962
3173
  return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message));
2963
3174
  }
2964
3175
  throw error;
@@ -3076,10 +3287,16 @@ async function handleResponsesInner(
3076
3287
  const rememberPassthroughResponseChecked = rememberPassthroughResponse
3077
3288
  ? (response: { id?: unknown; output?: unknown; status?: unknown }) => {
3078
3289
  if (inspectionSawUndeclaredTool) return;
3290
+ const restoredResponse = restoreRoutedCustomCalls(
3291
+ response,
3292
+ routedCustomToolNames,
3293
+ routedCustomToolRepairNames,
3294
+ declaredWireToolNames,
3295
+ ).value as { id?: unknown; output?: unknown; status?: unknown };
3079
3296
  if (
3080
3297
  undeclaredToolGuardActive
3081
3298
  && undeclaredToolCallNameInResponse(
3082
- response,
3299
+ restoredResponse,
3083
3300
  declaredWireToolNames,
3084
3301
  declaredNamelessClientCallTypes,
3085
3302
  providerExecutedCallTypes,
@@ -3087,7 +3304,7 @@ async function handleResponsesInner(
3087
3304
  ) {
3088
3305
  return;
3089
3306
  }
3090
- rememberPassthroughResponse(response);
3307
+ rememberPassthroughResponse(restoredResponse);
3091
3308
  }
3092
3309
  : undefined;
3093
3310
  recordAdapterReasoning(logCtx, request);
@@ -3541,7 +3758,7 @@ async function handleResponsesInner(
3541
3758
  }
3542
3759
  const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
3543
3760
  const resolvedModel = headers.get("openai-model")?.trim();
3544
- if (resolvedModel) logCtx.resolvedModel = resolvedModel;
3761
+ if (resolvedModel && !logCtx.preserveResolvedModelFromRoute) logCtx.resolvedModel = resolvedModel;
3545
3762
  if (isUsageDebugEnabled()) {
3546
3763
  const upstreamContentType = upstreamResponse.headers.get("content-type");
3547
3764
  if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType;
@@ -3716,6 +3933,7 @@ async function handleResponsesInner(
3716
3933
  routedCustomToolNames,
3717
3934
  translatorBudget,
3718
3935
  routedCustomToolRepairNames,
3936
+ declaredWireToolNames,
3719
3937
  )
3720
3938
  : undefined,
3721
3939
  routedToolSearchNames.size > 0
@@ -3926,6 +4144,7 @@ async function handleResponsesInner(
3926
4144
  restoredNamespace,
3927
4145
  routedCustomToolNames,
3928
4146
  routedCustomToolRepairNames,
4147
+ declaredWireToolNames,
3929
4148
  );
3930
4149
  const restoredToolSearch = restoreRoutedToolSearchCallsInJson(
3931
4150
  restored,
@@ -4082,6 +4301,49 @@ async function handleResponsesInner(
4082
4301
  const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined;
4083
4302
  const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined;
4084
4303
  const canRunWebSearch = !!wsPlan && !adapter.runTurn;
4304
+ const rotateSidecarProviderOn429 = async (retryAfter: string | null): Promise<ProviderAdapter | null> => {
4305
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
4306
+ retryAfter,
4307
+ now: Date.now(),
4308
+ attemptedKey: route.provider.apiKey,
4309
+ promptCacheKey: parsed.options.promptCacheKey,
4310
+ });
4311
+ if (rotated) {
4312
+ route.provider = rotated;
4313
+ } else {
4314
+ if (
4315
+ !genericFailoverAccountId
4316
+ || genericFailovers >= GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
4317
+ || !isGenericOAuthFailoverEnabled(config, route.providerName)
4318
+ ) return null;
4319
+ const nextAccountId = rotateGenericOAuthAccountOn429(
4320
+ config,
4321
+ route.providerName,
4322
+ genericFailoverAccountId,
4323
+ retryAfter,
4324
+ );
4325
+ if (!nextAccountId) return null;
4326
+ try {
4327
+ const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
4328
+ genericFailoverAccountId = nextAccountId;
4329
+ genericFailovers += 1;
4330
+ if (!applyFailoverSnapshot(snapshot)) return null;
4331
+ } catch {
4332
+ return null;
4333
+ }
4334
+ }
4335
+ const rotatedAdapter = resolveAdapter(
4336
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
4337
+ config.cacheRetention,
4338
+ );
4339
+ bindRouteReasoningReplayScope({
4340
+ parsed,
4341
+ providerName: route.providerName,
4342
+ provider: route.provider,
4343
+ adapterName: rotatedAdapter.name,
4344
+ });
4345
+ return rotatedAdapter;
4346
+ };
4085
4347
  if ((imgPlan || vidPlan) && canRunWebSearch) {
4086
4348
  // Web search takes priority when both are active — the media bridge cannot run
4087
4349
  // alongside runWithWebSearch. Surface a runtime signal so the user knows their
@@ -4172,27 +4434,7 @@ async function handleResponsesInner(
4172
4434
  if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
4173
4435
  }
4174
4436
  },
4175
- on429: retryAfter => {
4176
- const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
4177
- retryAfter,
4178
- now: Date.now(),
4179
- attemptedKey: route.provider.apiKey,
4180
- promptCacheKey: parsed.options.promptCacheKey,
4181
- });
4182
- if (!rotated) return null;
4183
- route.provider = rotated;
4184
- const rotatedAdapter = resolveAdapter(
4185
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
4186
- config.cacheRetention,
4187
- );
4188
- bindRouteReasoningReplayScope({
4189
- parsed,
4190
- providerName: route.providerName,
4191
- provider: route.provider,
4192
- adapterName: rotatedAdapter.name,
4193
- });
4194
- return rotatedAdapter;
4195
- },
4437
+ on429: rotateSidecarProviderOn429,
4196
4438
  retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
4197
4439
  ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
4198
4440
  ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
@@ -4260,27 +4502,7 @@ async function handleResponsesInner(
4260
4502
  routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
4261
4503
  stallTimeoutSec: wsPlan.stallTimeoutSec,
4262
4504
  streamRoutedModelOutput: wsPlan.streamRoutedModelOutput,
4263
- on429: retryAfter => {
4264
- const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
4265
- retryAfter,
4266
- now: Date.now(),
4267
- attemptedKey: route.provider.apiKey,
4268
- promptCacheKey: parsed.options.promptCacheKey,
4269
- });
4270
- if (!rotated) return null;
4271
- route.provider = rotated;
4272
- const rotatedAdapter = resolveAdapter(
4273
- resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
4274
- config.cacheRetention,
4275
- );
4276
- bindRouteReasoningReplayScope({
4277
- parsed,
4278
- providerName: route.providerName,
4279
- provider: route.provider,
4280
- adapterName: rotatedAdapter.name,
4281
- });
4282
- return rotatedAdapter;
4283
- },
4505
+ on429: rotateSidecarProviderOn429,
4284
4506
  retryOn429Policy: rateLimitRetryPolicyFor(route.provider),
4285
4507
  onCompletedResponse: commitReasoningReplayServingRoute,
4286
4508
  });
@@ -4352,7 +4574,7 @@ async function handleResponsesInner(
4352
4574
  pacingSlotAcquired: true,
4353
4575
  },
4354
4576
  );
4355
- await adapter.runTurn?.(
4577
+ await runTurnAdapter.runTurn?.(
4356
4578
  parsed,
4357
4579
  {
4358
4580
  headers: selectedForwardHeaders,
@@ -4385,6 +4607,77 @@ async function handleResponsesInner(
4385
4607
  }
4386
4608
  };
4387
4609
  const runTurn = async (): Promise<void> => runTurnAttempt(queue, undefined, true);
4610
+ const rotateRunTurnAdapterOnPreflight429 = async (
4611
+ error: Extract<AdapterEvent, { type: "error" }>,
4612
+ ): Promise<boolean> => {
4613
+ const status = error.status ?? adapterFailureFromMessage(error.message).httpStatus;
4614
+ if (
4615
+ status !== 429
4616
+ || !genericFailoverAccountId
4617
+ || genericFailovers >= GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
4618
+ || !isGenericOAuthFailoverEnabled(config, route.providerName)
4619
+ ) return false;
4620
+ const nextAccountId = rotateGenericOAuthAccountOn429(
4621
+ config,
4622
+ route.providerName,
4623
+ genericFailoverAccountId,
4624
+ null,
4625
+ );
4626
+ if (!nextAccountId) return false;
4627
+ try {
4628
+ const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
4629
+ genericFailoverAccountId = nextAccountId;
4630
+ genericFailovers += 1;
4631
+ if (!applyFailoverSnapshot(snapshot)) return false;
4632
+ // A Cursor conversation/checkpoint is credential-scoped. The failed attempt emitted no
4633
+ // client-visible bytes, so replay is safe, but carrying its account identity into the next
4634
+ // account would not be. Let the rotated adapter derive a fresh identity and conversation.
4635
+ parsed._cursorIdentityScope = undefined;
4636
+ parsed._cursorConversationId = undefined;
4637
+ if (parsed._providerContinuation?.cursor) {
4638
+ const { cursor: _discardedCursor, ...otherProviderState } = parsed._providerContinuation;
4639
+ parsed._providerContinuation = otherProviderState;
4640
+ }
4641
+ const rotatedProvider = resolveWireProtocolOverride(
4642
+ route.providerName,
4643
+ route.modelId,
4644
+ route.provider,
4645
+ inboundWire,
4646
+ );
4647
+ const rotatedAdapter = resolveAdapter(rotatedProvider, config.cacheRetention);
4648
+ if (!rotatedAdapter.runTurn) return false;
4649
+ runTurnAdapter = rotatedAdapter;
4650
+ bindRouteReasoningReplayScope({
4651
+ parsed,
4652
+ providerName: route.providerName,
4653
+ provider: rotatedProvider,
4654
+ adapterName: rotatedAdapter.name,
4655
+ oauthCredentialSnapshot: { accountId: snapshot.accountId, generation: snapshot.generation },
4656
+ codexAuthContext: authCtx,
4657
+ forwardHeaders: selectedForwardHeaders,
4658
+ });
4659
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, rotatedAdapter.name, logCtx.accountLogLabel);
4660
+ return true;
4661
+ } catch {
4662
+ return false;
4663
+ }
4664
+ };
4665
+ const preflightRunTurnFailover = async (
4666
+ firstSource: AsyncIterable<AdapterEvent>,
4667
+ ): Promise<AsyncIterable<AdapterEvent>> => {
4668
+ let source = firstSource;
4669
+ while (true) {
4670
+ const preflight = await preflightAdapterEvents(source);
4671
+ if (!preflight.error || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) {
4672
+ return preflight.stream;
4673
+ }
4674
+ const retryQueue = createAdapterEventQueue({
4675
+ onBacklogExceeded: () => runTurnAbort.abort(),
4676
+ });
4677
+ void runTurnAttempt(retryQueue, "oauth-account-429");
4678
+ source = retryQueue.stream();
4679
+ }
4680
+ };
4388
4681
  // The empty-completion retry re-runs the turn against a fresh queue: the
4389
4682
  // first queue is closed once its attempt settles, and pushing into it after
4390
4683
  // close is a silent no-op.
@@ -4400,6 +4693,11 @@ async function handleResponsesInner(
4400
4693
  if (parsed.stream) {
4401
4694
  void runTurn();
4402
4695
  let eventSource: AsyncIterable<AdapterEvent> = queue.stream();
4696
+ if (genericFailoverAccountId && isGenericOAuthFailoverEnabled(config, route.providerName)) {
4697
+ // Preflight holds only heartbeats and the first meaningful event. A first-event 429 can be
4698
+ // replayed transparently; after any output reaches the bridge, a later error stays terminal.
4699
+ eventSource = await preflightRunTurnFailover(eventSource);
4700
+ }
4403
4701
  if (options.comboAttempt) {
4404
4702
  const preflight = await preflightAdapterEvents(eventSource);
4405
4703
  if (preflight.error || preflight.empty) {
@@ -4417,7 +4715,16 @@ async function handleResponsesInner(
4417
4715
  // signal — run the adapter transport again against a fresh queue.
4418
4716
  continuation: runTurnRetrySource,
4419
4717
  })
4420
- : eventSource;
4718
+ // Guard off (the default): leave the stream alone, but record that the turn ended
4719
+ // empty so the user has something to correlate instead of an unexplained blank
4720
+ // result (#2472). Retrying by default would re-send a turn that may already have had
4721
+ // billable side effects, so the honest default is observability, not recovery.
4722
+ : observeEmptyCompletion(eventSource, () => {
4723
+ console.warn(
4724
+ `[opencodex] ${route.providerName}/${route.modelId} completed with no output text `
4725
+ + "and no tool call. Set \"emptyCompletionRetry\": true to retry such turns once.",
4726
+ );
4727
+ });
4421
4728
  const sseStream = bridgeToResponsesSSE(
4422
4729
  guardedSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
4423
4730
  () => {
@@ -4470,15 +4777,22 @@ async function handleResponsesInner(
4470
4777
 
4471
4778
  await runTurn();
4472
4779
  const firstAttemptEvents = await queue.collect();
4780
+ let runTurnEvents: AdapterEvent[] = firstAttemptEvents;
4781
+ if (genericFailoverAccountId && isGenericOAuthFailoverEnabled(config, route.providerName)) {
4782
+ runTurnEvents = [];
4783
+ for await (const event of await preflightRunTurnFailover(
4784
+ (async function* () { yield* firstAttemptEvents; })(),
4785
+ )) runTurnEvents.push(event);
4786
+ }
4473
4787
  let events: AdapterEvent[];
4474
4788
  if (emptyCompletionGuardEnabled) {
4475
4789
  events = [];
4476
4790
  for await (const event of guardEmptyCompletionEventStream({
4477
- firstEvents: (async function* () { yield* firstAttemptEvents; })(),
4791
+ firstEvents: (async function* () { yield* runTurnEvents; })(),
4478
4792
  continuation: runTurnRetrySource,
4479
4793
  })) events.push(event);
4480
4794
  } else {
4481
- events = firstAttemptEvents;
4795
+ events = runTurnEvents;
4482
4796
  }
4483
4797
  if (options.comboAttempt) {
4484
4798
  const firstMeaningful = events.find(event => event.type !== "heartbeat");
@@ -4877,6 +5191,49 @@ async function handleResponsesInner(
4877
5191
  break;
4878
5192
  }
4879
5193
  }
5194
+ // Generic OAuth account failover (#2568) for providers with no pool of their own.
5195
+ // Presence is consent since #2568d: rotation is ON by default once two or more eligible
5196
+ // accounts are stored for the provider, because a second deliberate login is read as the
5197
+ // operator asking for it. A single-account install is still a strict no-op, and an
5198
+ // explicit `oauthAccountFailover.enabled: false` (global or per provider) still wins --
5199
+ // see isGenericOAuthFailoverEnabled in src/oauth/generic-account-failover.ts. Codex and
5200
+ // Anthropic are excluded by isGenericFailoverProvider: their pools own quota scopes,
5201
+ // probe leases and affinity that this must not reimplement.
5202
+ while (
5203
+ upstreamResponse.status === 429
5204
+ && genericFailoverAccountId
5205
+ && genericFailovers < GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST
5206
+ && isGenericOAuthFailoverEnabled(config, route.providerName)
5207
+ ) {
5208
+ const nextAccountId = rotateGenericOAuthAccountOn429(
5209
+ config,
5210
+ route.providerName,
5211
+ genericFailoverAccountId,
5212
+ upstreamResponse.headers.get("retry-after"),
5213
+ );
5214
+ if (!nextAccountId) break;
5215
+ try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
5216
+ try {
5217
+ // The FULL snapshot, not just the bearer: Antigravity pairs an account-matched
5218
+ // projectId with its token and Kiro carries routing metadata, so a token-only swap
5219
+ // would mix one account's credential with another's routing data.
5220
+ const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
5221
+ genericFailoverAccountId = nextAccountId;
5222
+ genericFailovers += 1;
5223
+ if (!applyFailoverSnapshot(snapshot)) break;
5224
+ invalidateSameTargetRequest();
5225
+ activeAdapter = resolveAdapter(
5226
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
5227
+ config.cacheRetention,
5228
+ );
5229
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name, logCtx.accountLogLabel);
5230
+ const result = await rebuildAndRefetch("oauth-account-429");
5231
+ if ("failed" in result) return result.failed;
5232
+ upstreamResponse = result;
5233
+ } catch {
5234
+ break;
5235
+ }
5236
+ }
4880
5237
  // Unknown provenance is deliberately fail-soft in pre-flight: after a restart, TTL expiry,
4881
5238
  // or LRU eviction, a valid same-backend blob must survive. A decoder's own 4xx identity is
4882
5239
  // the missing authoritative signal. Rebuild once through the same sanitation path used by a
@@ -4952,29 +5309,41 @@ async function handleResponsesInner(
4952
5309
  // Upstreams occasionally echo request details in error bodies — scrub token-shaped
4953
5310
  // material before it reaches the client-facing error surface.
4954
5311
  const upstreamRetryAfter = upstreamResponse.headers.get("retry-after");
4955
- const message = enrichOpenCodeZenRateLimitMessage(
4956
- `Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`,
4957
- {
5312
+ const normalized = normalizeUpstreamErrorText(errorText, "unknown error");
5313
+ const message = normalized.cyberPolicy
5314
+ ? normalized.message
5315
+ ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText)
5316
+ : enrichOpenCodeZenRateLimitMessage(
5317
+ `Provider error ${upstreamResponse.status}: ${normalized.safeText}`,
5318
+ {
5319
+ status: upstreamResponse.status,
5320
+ providerName: route.providerName,
5321
+ baseUrl: route.provider.baseUrl,
5322
+ adapter: route.provider.adapter,
5323
+ authMode: route.provider.authMode,
5324
+ hasApiKey: Boolean(route.provider.apiKey?.trim()),
5325
+ upstreamRetryAfter,
5326
+ // This recovery path is the HTTP Responses wire; custom runTurn transports
5327
+ // never reach enrichOpenCodeZenRateLimitMessage here.
5328
+ supportsHttpSameKeyRetry: true,
5329
+ },
5330
+ );
5331
+ const retryAfter = normalized.cyberPolicy
5332
+ ? undefined
5333
+ : resolveClientRetryAfter({
4958
5334
  status: upstreamResponse.status,
4959
- providerName: route.providerName,
4960
- baseUrl: route.provider.baseUrl,
4961
- adapter: route.provider.adapter,
4962
- authMode: route.provider.authMode,
4963
- hasApiKey: Boolean(route.provider.apiKey?.trim()),
5335
+ message,
4964
5336
  upstreamRetryAfter,
4965
- // This recovery path is the HTTP Responses wire; custom runTurn transports
4966
- // never reach enrichOpenCodeZenRateLimitMessage here.
4967
- supportsHttpSameKeyRetry: true,
5337
+ });
5338
+ return formatErrorResponse(
5339
+ upstreamResponse.status,
5340
+ normalized.cyberPolicy ? (normalized.type ?? CYBER_POLICY_ERROR_CODE) : "upstream_error",
5341
+ message,
5342
+ {
5343
+ ...(normalized.cyberPolicy ? { code: CYBER_POLICY_ERROR_CODE } : {}),
5344
+ ...(retryAfter !== undefined ? { retryAfter } : {}),
4968
5345
  },
4969
5346
  );
4970
- const retryAfter = resolveClientRetryAfter({
4971
- status: upstreamResponse.status,
4972
- message,
4973
- upstreamRetryAfter,
4974
- });
4975
- return formatErrorResponse(upstreamResponse.status, "upstream_error", message, {
4976
- ...(retryAfter !== undefined ? { retryAfter } : {}),
4977
- });
4978
5347
  }
4979
5348
  }
4980
5349
 
@@ -5229,10 +5598,21 @@ async function handleResponsesInner(
5229
5598
 
5230
5599
  if (!response.ok) {
5231
5600
  const errorText = await readDisplaySafeErrorText(response, upstream.signal, "unknown error");
5601
+ const normalized = normalizeUpstreamErrorText(errorText, "unknown error");
5232
5602
  yield {
5233
5603
  type: "error",
5234
- status: response.status,
5235
- message: `Provider continuation error ${response.status}: ${redactSecretString(errorText.slice(0, 500))}`,
5604
+ status: normalized.cyberPolicy ? 400 : response.status,
5605
+ message: normalized.cyberPolicy
5606
+ ? normalized.message
5607
+ ?? (isCyberPolicyCode(normalized.code) ? CYBER_POLICY_FALLBACK_MESSAGE : normalized.safeText)
5608
+ : `Provider continuation error ${response.status}: ${normalized.safeText}`,
5609
+ ...(normalized.cyberPolicy
5610
+ ? {
5611
+ errorType: normalized.type ?? CYBER_POLICY_ERROR_CODE,
5612
+ code: CYBER_POLICY_ERROR_CODE,
5613
+ retryable: false,
5614
+ }
5615
+ : {}),
5236
5616
  };
5237
5617
  return;
5238
5618
  }