@bitkyc08/opencodex 2.64.0 → 2.65.0-preview.20260925

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 (208) hide show
  1. package/README.md +28 -25
  2. package/bin/ocx.mjs +16 -2
  3. package/gui/dist/assets/App-8NMiZxT0.css +1 -0
  4. package/gui/dist/assets/App-CsYvvpr3.js +50 -0
  5. package/gui/dist/assets/Tray-DUvc_Wul.js +1 -0
  6. package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
  7. package/gui/dist/assets/{index-SggB6t3z.js → index-BB0iHG8-.js} +12 -12
  8. package/gui/dist/assets/tray-data-f0lTZ4sF.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -4
  11. package/src/adapters/anthropic.ts +30 -1
  12. package/src/adapters/claude-cli/adapter.ts +218 -0
  13. package/src/adapters/claude-cli/profiles.ts +38 -0
  14. package/src/adapters/codebuddy/profiles.ts +2 -0
  15. package/src/adapters/coding-agent/profile.ts +12 -4
  16. package/src/adapters/coding-agent/turn.ts +16 -6
  17. package/src/adapters/command-code-tool-text.ts +133 -23
  18. package/src/adapters/cursor/catalog.ts +4 -8
  19. package/src/adapters/cursor/discovery.ts +10 -5
  20. package/src/adapters/cursor/effort-map.ts +2 -6
  21. package/src/adapters/cursor/envelope-echo.ts +10 -5
  22. package/src/adapters/cursor/request-builder.ts +9 -4
  23. package/src/adapters/cursor/thread-continuity.ts +53 -0
  24. package/src/adapters/cursor.ts +29 -13
  25. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  26. package/src/adapters/google-tool-schema.ts +26 -3
  27. package/src/adapters/identity.ts +214 -2
  28. package/src/adapters/openai-chat/passthrough.ts +1 -0
  29. package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
  30. package/src/adapters/openai-chat.ts +43 -15
  31. package/src/adapters/openai-responses/canonical-forward.ts +17 -10
  32. package/src/adapters/openai-responses/passthrough.ts +19 -4
  33. package/src/adapters/openai-responses/request-strips.ts +25 -0
  34. package/src/adapters/openai-responses/web-search.ts +22 -4
  35. package/src/adapters/qoder/profiles.ts +2 -0
  36. package/src/adapters/registry.ts +8 -0
  37. package/src/adapters/run-turn-queue.ts +7 -0
  38. package/src/bridge/response-json.ts +6 -4
  39. package/src/bridge/sse.ts +7 -5
  40. package/src/claude/alias.ts +73 -26
  41. package/src/claude/context-windows.ts +15 -6
  42. package/src/claude/desktop-3p-library.ts +3 -2
  43. package/src/claude/desktop-3p.ts +1 -1
  44. package/src/claude/desktop-first-party.ts +63 -22
  45. package/src/claude/desktop-picker-profile.ts +302 -0
  46. package/src/claude/desktop-picker.ts +365 -0
  47. package/src/claude/desktop-risk.ts +20 -0
  48. package/src/claude/gateway-cache.ts +13 -4
  49. package/src/claude/inbound.ts +5 -2
  50. package/src/claude/intercept/connect-proxy.ts +106 -29
  51. package/src/claude/intercept/local-ca.ts +76 -18
  52. package/src/claude/intercept/model-bindings.ts +145 -0
  53. package/src/claude/intercept/picker-bootstrap.ts +141 -0
  54. package/src/claude/intercept/picker-ca.ts +126 -0
  55. package/src/claude/intercept/picker-listener.ts +213 -0
  56. package/src/claude/intercept/picker-models.ts +101 -0
  57. package/src/claude/intercept/picker-runtime.ts +330 -0
  58. package/src/claude/intercept/picker-trust.ts +125 -0
  59. package/src/claude/intercept/runtime.ts +149 -2
  60. package/src/claude/model-info.ts +18 -4
  61. package/src/cli/agent.ts +16 -2
  62. package/src/cli/capabilities.ts +71 -2
  63. package/src/cli/claude-desktop.ts +319 -33
  64. package/src/cli/claude.ts +15 -7
  65. package/src/cli/codex-shim-autorestore.ts +1 -0
  66. package/src/cli/dispatch.ts +29 -23
  67. package/src/cli/effort.ts +16 -6
  68. package/src/cli/ensure-desired-integrations.ts +29 -4
  69. package/src/cli/ready.ts +1 -1
  70. package/src/cli/registry.ts +8 -0
  71. package/src/cli/restart-scope.ts +9 -1
  72. package/src/cli/runtime-api.ts +17 -0
  73. package/src/cli/system-command.ts +11 -12
  74. package/src/client/connect.ts +12 -2
  75. package/src/client/state.ts +39 -1
  76. package/src/clients/config-export.ts +57 -9
  77. package/src/codex/auth-context.ts +51 -11
  78. package/src/codex/catalog/derive-entry.ts +5 -5
  79. package/src/codex/catalog/gather-capture.ts +25 -6
  80. package/src/codex/catalog/metadata.ts +3 -3
  81. package/src/codex/catalog/parsing.ts +39 -1
  82. package/src/codex/catalog/provider-models.ts +8 -6
  83. package/src/codex/catalog/retained-sync.ts +17 -7
  84. package/src/codex/catalog/sync.ts +2 -2
  85. package/src/codex/desktop-app-restart.ts +8 -0
  86. package/src/codex/desktop-switches.ts +9 -1
  87. package/src/codex/home.ts +43 -4
  88. package/src/codex/inject/config-toml.ts +137 -0
  89. package/src/codex/inject/plan.ts +23 -0
  90. package/src/codex/inject/remove.ts +21 -1
  91. package/src/codex/inject.ts +63 -8
  92. package/src/codex/journal.ts +36 -0
  93. package/src/codex/main-account-hard-lock.ts +14 -1
  94. package/src/codex/main-account-policy-wait.ts +109 -0
  95. package/src/codex/native-profile-startup.ts +24 -6
  96. package/src/codex/native-residue.ts +28 -10
  97. package/src/codex/quota-types.ts +8 -1
  98. package/src/codex/quota.ts +49 -7
  99. package/src/codex/runtime.ts +31 -10
  100. package/src/combos/failover.ts +7 -6
  101. package/src/combos/resolve.ts +91 -14
  102. package/src/combos/types.ts +18 -1
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/schema/config-schema.ts +21 -2
  105. package/src/config/schema/leaf-validators.ts +6 -3
  106. package/src/config/subagent-models.ts +3 -1
  107. package/src/generated/compatibility-version.json +275 -163
  108. package/src/images/artifacts.ts +14 -0
  109. package/src/integrations/owned-refresh.ts +1 -1
  110. package/src/integrations/ownership-policy.ts +32 -1
  111. package/src/integrations/state.ts +3 -1
  112. package/src/integrations/writer.ts +9 -0
  113. package/src/lib/config-ownership.ts +3 -0
  114. package/src/lib/errors.ts +87 -0
  115. package/src/lib/package-tree-integrity.ts +1 -1
  116. package/src/lib/request-execution-budget.ts +42 -14
  117. package/src/lib/request-resend-gate.ts +4 -3
  118. package/src/lib/retry-delay.ts +7 -1
  119. package/src/lib/tool-envelope-echo-filter.ts +236 -0
  120. package/src/lib/upstream-retry.ts +20 -12
  121. package/src/oauth/index.ts +1 -0
  122. package/src/oauth/kiro-credentials.ts +14 -1
  123. package/src/oauth/login-cli.ts +1 -0
  124. package/src/providers/derive.ts +4 -0
  125. package/src/providers/fast-opt-in.ts +31 -0
  126. package/src/providers/model-rename-fields.ts +2 -0
  127. package/src/providers/provider-id-rewrite.ts +2 -0
  128. package/src/providers/quota/vendor-probes-key.ts +8 -2
  129. package/src/providers/registry/entries-core.ts +45 -2
  130. package/src/providers/registry/entries-extended.ts +99 -12
  131. package/src/providers/registry/model-ids.ts +2 -0
  132. package/src/providers/registry/types.ts +7 -1
  133. package/src/providers/resolved-model-policy.ts +11 -4
  134. package/src/providers/service-tier.ts +10 -3
  135. package/src/responses/code-mode-helper-compat.ts +3 -0
  136. package/src/responses/parser.ts +45 -3
  137. package/src/router.ts +6 -0
  138. package/src/server/auth-cors.ts +8 -0
  139. package/src/server/chat-completions.ts +3 -1
  140. package/src/server/claude-messages.ts +69 -14
  141. package/src/server/grok-upstream-envelope-echo.ts +120 -0
  142. package/src/server/index/claude-intercept-lifecycle.ts +24 -0
  143. package/src/server/index/serve-options.ts +2 -2
  144. package/src/server/index.ts +7 -0
  145. package/src/server/management/agent-settings-routes.ts +204 -112
  146. package/src/server/management/claude-desktop-picker-routes.ts +128 -0
  147. package/src/server/management/combo-routes.ts +59 -2
  148. package/src/server/management/config-routes.ts +81 -39
  149. package/src/server/management/context.ts +3 -0
  150. package/src/server/management/native-integration-routes.ts +169 -93
  151. package/src/server/management/provider-routes.ts +46 -4
  152. package/src/server/management/route-registry.ts +10 -0
  153. package/src/server/management/routing-profile-routes.ts +9 -0
  154. package/src/server/management/sidebar-routes.ts +52 -0
  155. package/src/server/management-api.ts +2 -0
  156. package/src/server/proxy-liveness.ts +38 -3
  157. package/src/server/request-log-account-rotation.ts +53 -0
  158. package/src/server/request-log.ts +3 -16
  159. package/src/server/responses/adapter-dispatch.ts +17 -1
  160. package/src/server/responses/agent-task-recovery.ts +49 -26
  161. package/src/server/responses/codex-ws-exchange.ts +18 -7
  162. package/src/server/responses/codex-ws-wire.ts +27 -4
  163. package/src/server/responses/core-combo.ts +21 -3
  164. package/src/server/responses/core-normalize.ts +7 -0
  165. package/src/server/responses/core-opaque-recovery.ts +3 -2
  166. package/src/server/responses/encrypted-payload.ts +15 -14
  167. package/src/server/responses/fetch-helpers.ts +1 -1
  168. package/src/server/responses/input-admission.ts +10 -6
  169. package/src/server/responses/native-response-control.ts +2 -2
  170. package/src/server/responses/passthrough-delivery.ts +182 -12
  171. package/src/server/responses/passthrough-dispatch.ts +102 -44
  172. package/src/server/responses/policy-fallback.ts +3 -0
  173. package/src/server/responses/policy-refusal.ts +67 -0
  174. package/src/server/responses/request-send-budget.ts +4 -0
  175. package/src/server/responses/run-turn-execution.ts +42 -11
  176. package/src/server/responses/ws-upstream.ts +2 -1
  177. package/src/server/system-env-shell.ts +0 -1
  178. package/src/server/system-env.ts +25 -4
  179. package/src/service/cli.ts +34 -3
  180. package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
  181. package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
  182. package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
  183. package/src/tray/windows-tray.ps1 +188 -7
  184. package/src/tray/windows.ts +23 -2
  185. package/src/types/config.ts +52 -12
  186. package/src/types/provider.ts +22 -5
  187. package/src/types/tools.ts +11 -5
  188. package/src/types.ts +1 -0
  189. package/src/update/async-check.ts +109 -0
  190. package/src/update/badge.ts +11 -17
  191. package/src/update/check-types.ts +9 -0
  192. package/src/update/desktop-badge.ts +102 -0
  193. package/src/update/index.ts +48 -8
  194. package/src/update/install-detection.d.mts +29 -2
  195. package/src/update/install-detection.mjs +213 -7
  196. package/src/update/job.ts +17 -12
  197. package/src/update/notify.ts +16 -11
  198. package/src/update/pnpm-owner-worker.ts +12 -0
  199. package/src/update/refresh-scheduler.ts +141 -0
  200. package/src/usage/cost.ts +31 -4
  201. package/src/usage/expected-prices.ts +57 -0
  202. package/assets/download-linux.svg +0 -10
  203. package/assets/download-macos.svg +0 -10
  204. package/assets/download-windows.svg +0 -10
  205. package/gui/dist/assets/App-CpuDF3ci.js +0 -50
  206. package/gui/dist/assets/App-I5AnaSLh.css +0 -1
  207. package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
  208. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
@@ -84,12 +84,47 @@ export interface LivenessIo {
84
84
  createChallengeFn?: () => string;
85
85
  }
86
86
 
87
+ /**
88
+ * Operator override for the per-probe fetch ceilings below (`OCX_PROBE_TIMEOUT_MS`),
89
+ * integer milliseconds in [1, MAX_PROBE_TIMEOUT_MS].
90
+ *
91
+ * Some hosts put a security layer (content filter, EDR network extension) in front of
92
+ * loopback TCP that adds a fixed per-connect cost, measured at about one second on an
93
+ * affected macOS machine. The shipped 750 ms probe then aborts before a healthy proxy can
94
+ * answer, and every CLI liveness consumer reports the proxy as down while a direct
95
+ * `curl /healthz` succeeds.
96
+ *
97
+ * The override only raises: each ceiling keeps its shipped floor (750 ms for the shared
98
+ * default, 1500 ms for the stop/start ownership budgets that guard against a duplicate
99
+ * proxy, #764, #5004), so a small value can never shorten them. Values above the 30 s
100
+ * ceiling are ignored rather than clamped: a stop multiplies its budget by the attempt
101
+ * count, and a typo must not turn a stop into a wait of minutes or days. Parsed once at
102
+ * module load; anything malformed falls back to the defaults and never breaks startup.
103
+ */
104
+ export const MAX_PROBE_TIMEOUT_MS = 30_000;
105
+ const SHARED_PROBE_FLOOR_MS = 750;
106
+ const OWNERSHIP_PROBE_FLOOR_MS = 1500;
107
+
108
+ export function parseProbeTimeoutOverrideMs(raw: string | undefined): number | undefined {
109
+ const trimmed = raw?.trim();
110
+ if (!trimmed || !/^\d+$/.test(trimmed)) return undefined;
111
+ const n = Number(trimmed);
112
+ return n > 0 && n <= MAX_PROBE_TIMEOUT_MS ? n : undefined;
113
+ }
114
+
115
+ /** The ceiling for a probe whose shipped value is `floorMs`, raised by a valid override only. */
116
+ export function probeCeilingMs(floorMs: number, override: number | undefined): number {
117
+ return Math.max(floorMs, override ?? 0);
118
+ }
119
+
120
+ const probeTimeoutOverrideMs = parseProbeTimeoutOverrideMs(process.env.OCX_PROBE_TIMEOUT_MS);
121
+
87
122
  /** Default per-probe fetch ceiling shared by liveness and readiness probes. */
88
- export const DEFAULT_PROBE_TIMEOUT_MS = 750;
123
+ export const DEFAULT_PROBE_TIMEOUT_MS = probeCeilingMs(SHARED_PROBE_FLOOR_MS, probeTimeoutOverrideMs);
89
124
 
90
125
  /** Default probe options for service stop / orphan cleanup — a just-bound proxy can miss a single 750ms probe. */
91
126
  export const SERVICE_STOP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> = {
92
- timeoutMs: 1500,
127
+ timeoutMs: probeCeilingMs(OWNERSHIP_PROBE_FLOOR_MS, probeTimeoutOverrideMs),
93
128
  attempts: 3,
94
129
  };
95
130
 
@@ -105,7 +140,7 @@ export const SERVICE_STOP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> =
105
140
  * the stop path already uses for the mirror-image decision.
106
141
  */
107
142
  export const START_OWNERSHIP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"> = {
108
- timeoutMs: 1500,
143
+ timeoutMs: probeCeilingMs(OWNERSHIP_PROBE_FLOOR_MS, probeTimeoutOverrideMs),
109
144
  attempts: 3,
110
145
  };
111
146
 
@@ -0,0 +1,53 @@
1
+ import { ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
2
+ import { isCodexUsageAccountLogLabel, type PersistedUsageAttempt } from "../usage/log";
3
+
4
+ /**
5
+ * Did the account behind a sent attempt change between two sends of the same request?
6
+ *
7
+ * Any ACCOUNT change ends the attempt, not just an API-key one. Codex pool and generic OAuth
8
+ * labels are `p`/`o` + 6 hex, which the key-only pattern never matched, so a rotation folded the
9
+ * second account's sends, usage and label into the first attempt row: the request then reported
10
+ * one attempt on the wrong account, and per-account usage attribution was silently wrong.
11
+ * `isCodexUsageAccountLogLabel` (src/usage/log.ts) already persists those labels onto the
12
+ * attempt through the broad pattern; this is the same pattern, read back at the split.
13
+ *
14
+ * The other axis a rotation can move: Anthropic's pool carries no label at all --
15
+ * `stampOAuthAccountLabel` skips that base provider -- and keeps its account inside the
16
+ * account-qualified log provider string (`anthropic-p<hex6>`), so the label test can never see an
17
+ * Anthropic rotation. Comparing providers is safe because a sent attempt's provider is frozen by
18
+ * `sealRequestAttemptIdentity`: the only way the two diverge after a send is the account moving
19
+ * underneath the row.
20
+ */
21
+ export function attemptAccountChanged(
22
+ previousLabel: string | undefined,
23
+ nextLabel: string | undefined,
24
+ attemptProvider: string | undefined,
25
+ currentProvider: string,
26
+ ): boolean {
27
+ const labelChanged = previousLabel !== nextLabel
28
+ && (ACCOUNT_LOG_LABEL_RE.test(previousLabel ?? "") || ACCOUNT_LOG_LABEL_RE.test(nextLabel ?? ""));
29
+ return labelChanged || (attemptProvider !== undefined && attemptProvider !== currentProvider && currentProvider.length > 0);
30
+ }
31
+
32
+ /** Stamp an attempt's identity; after its first send the account is frozen (see attemptAccountChanged). */
33
+ export function sealRequestAttemptIdentity(
34
+ attempt: PersistedUsageAttempt | undefined,
35
+ provider: string,
36
+ adapter: string,
37
+ accountLogLabel?: string,
38
+ ): void {
39
+ if (!attempt) return;
40
+ if (attempt.provider !== provider || attempt.adapter !== adapter) delete attempt.credentialSource;
41
+ // The adapter keeps re-stamping: a mid-turn wire rotation is still this same physical send.
42
+ attempt.adapter = adapter;
43
+ if (attempt.sendCount === 0) {
44
+ attempt.provider = provider;
45
+ if (isCodexUsageAccountLogLabel(accountLogLabel)) attempt.accountLogLabel = accountLogLabel;
46
+ else delete attempt.accountLogLabel;
47
+ return;
48
+ }
49
+ // After a send the account is settled; only a label that arrives late for the same account lands.
50
+ if (attempt.accountLogLabel === undefined && isCodexUsageAccountLogLabel(accountLogLabel)) {
51
+ attempt.accountLogLabel = accountLogLabel;
52
+ }
53
+ }
@@ -2,6 +2,8 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { stampApiKeyAccountLabel, usesApiKeyAccount } from "../providers/label";
4
4
  import { KEY_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
5
+ import { attemptAccountChanged, sealRequestAttemptIdentity } from "./request-log-account-rotation";
6
+ export { sealRequestAttemptIdentity };
5
7
  import { readBoundedResponseBody } from "../lib/bounded-body";
6
8
  import type { ResponsesTerminalStatus } from "../bridge";
7
9
  import {
@@ -1748,20 +1750,6 @@ export function beginRequestAttempt(
1748
1750
  };
1749
1751
  }
1750
1752
 
1751
- export function sealRequestAttemptIdentity(
1752
- attempt: PersistedUsageAttempt | undefined,
1753
- provider: string,
1754
- adapter: string,
1755
- accountLogLabel?: string,
1756
- ): void {
1757
- if (!attempt) return;
1758
- if (attempt.provider !== provider || attempt.adapter !== adapter) delete attempt.credentialSource;
1759
- attempt.provider = provider;
1760
- attempt.adapter = adapter;
1761
- if (isCodexUsageAccountLogLabel(accountLogLabel)) attempt.accountLogLabel = accountLogLabel;
1762
- else delete attempt.accountLogLabel;
1763
- }
1764
-
1765
1753
  /** Preserve metered JSON failures before key recovery consumes/cancels their body. */
1766
1754
  export async function recordKeyAttemptFailure(logCtx: RequestLogContext, response: Response, signal?: AbortSignal): Promise<void> {
1767
1755
  const attempt = logCtx.activeAttempt;
@@ -1805,8 +1793,7 @@ export function noteProviderAttemptSend(
1805
1793
  stampApiKeyAccountLabel(logCtx, providerName, provider);
1806
1794
  const next = logCtx.accountLogLabel;
1807
1795
  if (attempt && usesApiKeyAccount(provider)) keyUsageOwners.add(attempt);
1808
- if (attempt && attempt.sendCount > 0 && previous !== next
1809
- && (KEY_ACCOUNT_LOG_LABEL_RE.test(previous ?? "") || KEY_ACCOUNT_LOG_LABEL_RE.test(next ?? ""))) {
1796
+ if (attempt && attempt.sendCount > 0 && attemptAccountChanged(previous, next, attempt.provider, logCtx.provider)) {
1810
1797
  // An input estimate is not evidence that a failed send used that many tokens.
1811
1798
  delete attempt.inputTokenEstimate;
1812
1799
  finishRequestAttempt(attempt, attempt.status >= 100 ? attempt.status
@@ -17,6 +17,7 @@ import {
17
17
  } from "../request-log";
18
18
  import { clientCancelledResponse, readDisplaySafeErrorText, normalizeUpstreamErrorText } from "./core-errors";
19
19
  import { redactSecretString } from "../../lib/redact";
20
+ import { rewriteUpstreamPolicyRefusal } from "./policy-refusal";
20
21
  import { waitForProviderRequestSlot } from "../../providers/request-pacing";
21
22
  import { providerFetch, fetchWithHeaderTimeout, safeHostLabel } from "./fetch-helpers";
22
23
  import {
@@ -41,7 +42,7 @@ import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
41
42
  import type { AttemptRecoveryKind } from "../../usage/log";
42
43
  import type { OAuthAccessSnapshot } from "../../oauth";
43
44
  import { publicOAuthAuthenticationErrorMessage } from "../../oauth";
44
- import { resolveProviderTransport } from "../../providers/xai-transport";
45
+ import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport";
45
46
  import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
46
47
  import { resolveWireProtocolOverride } from "../adapter-resolve";
47
48
  import { bindRouteReasoningReplayScope } from "./core-replay";
@@ -1066,6 +1067,21 @@ export async function prepareAdapterExchange(
1066
1067
  ? streamingContextOverflowResponse(parsed._responseModelId ?? parsed.modelId, translatorBudget)
1067
1068
  : jsonContextOverflowResponse();
1068
1069
  }
1070
+ const policyRefusal = rewriteUpstreamPolicyRefusal({
1071
+ status: upstreamResponse.status,
1072
+ errorText,
1073
+ stream: clientRequestedStream,
1074
+ modelId: parsed._responseModelId ?? parsed.modelId,
1075
+ // The same host check covers the openai-chat wire: both xAI hosts serve Chat too.
1076
+ destinationIsXai: isXaiResponsesDestination(route.provider),
1077
+ translatorBudget,
1078
+ turnAdmissionLease: options.turnAdmissionLease,
1079
+ });
1080
+ if (policyRefusal) {
1081
+ // Codex-facing incomplete/content_filter. openai-responses passthrough
1082
+ // uses the same helper after its 413 block.
1083
+ return policyRefusal;
1084
+ }
1069
1085
  if (!isFixedCodexAccount(admissionState.authCtx)) {
1070
1086
  recordSubagentQuotaFailureForThreadSpawn(
1071
1087
  req.headers,
@@ -75,15 +75,17 @@ interface AgentEnvelope {
75
75
  encryptedStartIndex: number;
76
76
  inputSnapshot: string;
77
77
  headerText: string;
78
- messageType: "NEW_TASK" | "MESSAGE";
79
- taskName: string;
78
+ messageType: "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK" | "FINAL_ANSWER";
79
+ taskName: string | null;
80
80
  sender: string;
81
81
  ciphertexts: readonly string[];
82
82
  author: string;
83
83
  recipient: string;
84
84
  }
85
85
 
86
- const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
86
+ const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE|FOLLOWUP_TASK)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
87
+ // FINAL_ANSWER omits the Task name line when the sender declares no recipient.
88
+ const FINAL_ANSWER_HEADER = /(?:^|\n)Message Type\s*:\s*FINAL_ANSWER\s*\n(?:Task name\s*:\s*(\S+)\s*\n)?Sender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/;
87
89
 
88
90
  function findEnvelope(input: unknown): AgentEnvelope | null {
89
91
  if (!Array.isArray(input)) return null;
@@ -104,7 +106,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
104
106
  if (!Array.isArray(content)) return null;
105
107
 
106
108
  let headerText: string | null = null;
107
- let messageType: "NEW_TASK" | "MESSAGE" | null = null;
109
+ let messageType: "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK" | "FINAL_ANSWER" | null = null;
108
110
  let taskName: string | null = null;
109
111
  let sender: string | null = null;
110
112
  let encryptedStartIndex = -1;
@@ -119,16 +121,24 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
119
121
  && typeof part.text === "string"
120
122
  ) {
121
123
  const match = ROUTING_HEADER.exec(part.text);
122
- if (match) {
124
+ const finalMatch = match ? null : FINAL_ANSWER_HEADER.exec(part.text);
125
+ if (match || finalMatch) {
123
126
  if (headerText !== null) return null;
127
+ const m = match ?? finalMatch!;
124
128
  if (
125
- part.text.slice(0, match.index).trim().length > 0
126
- || part.text.slice(match.index + match[0].length).trim().length > 0
129
+ part.text.slice(0, m.index).trim().length > 0
130
+ || part.text.slice(m.index + m[0].length).trim().length > 0
127
131
  ) return null;
128
- headerText = match[0].startsWith("\n") ? match[0].slice(1) : match[0];
129
- messageType = match[1] as "NEW_TASK" | "MESSAGE";
130
- taskName = match[2]!;
131
- sender = match[3]!;
132
+ headerText = m[0].startsWith("\n") ? m[0].slice(1) : m[0];
133
+ if (match) {
134
+ messageType = match[1] as "NEW_TASK" | "MESSAGE" | "FOLLOWUP_TASK";
135
+ taskName = match[2]!;
136
+ sender = match[3]!;
137
+ } else {
138
+ messageType = "FINAL_ANSWER";
139
+ taskName = finalMatch![1] ?? null;
140
+ sender = finalMatch![2]!;
141
+ }
132
142
  }
133
143
  }
134
144
  if (part.type !== "encrypted_content") continue;
@@ -145,7 +155,6 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
145
155
  if (
146
156
  !headerText
147
157
  || !messageType
148
- || !taskName
149
158
  || !sender
150
159
  || encryptedStartIndex < 0
151
160
  || ciphertexts.length === 0
@@ -153,7 +162,13 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
153
162
 
154
163
  const itemRecord = item as { author?: unknown; recipient?: unknown };
155
164
  if (typeof itemRecord.author !== "string" || typeof itemRecord.recipient !== "string") return null;
156
- if (itemRecord.author !== sender || itemRecord.recipient !== taskName) return null;
165
+ // A FINAL_ANSWER without a Task name line declares no recipient, so the structured
166
+ // recipient is only cross-checked when a task name is present; admission is the
167
+ // trust boundary either way.
168
+ if (
169
+ itemRecord.author !== sender
170
+ || (taskName !== null && itemRecord.recipient !== taskName)
171
+ ) return null;
157
172
 
158
173
  return {
159
174
  itemIndex,
@@ -170,10 +185,15 @@ function findEnvelope(input: unknown): AgentEnvelope | null {
170
185
  }
171
186
 
172
187
  function stripMatchingEnvelope(assignment: string, envelope: AgentEnvelope): string | null {
173
- const match = ROUTING_HEADER.exec(assignment);
188
+ const header = envelope.messageType === "FINAL_ANSWER" ? FINAL_ANSWER_HEADER : ROUTING_HEADER;
189
+ const foreign = header === FINAL_ANSWER_HEADER ? ROUTING_HEADER : FINAL_ANSWER_HEADER;
190
+ if (foreign.test(assignment)) return null;
191
+ const match = header.exec(assignment);
174
192
  if (!match) return assignment;
175
193
  if (match.index !== 0) return null;
176
- if (
194
+ if (envelope.messageType === "FINAL_ANSWER") {
195
+ if ((match[1] ?? null) !== envelope.taskName || match[2] !== envelope.sender) return null;
196
+ } else if (
177
197
  match[1] !== envelope.messageType
178
198
  || match[2] !== envelope.taskName
179
199
  || match[3] !== envelope.sender
@@ -295,18 +315,21 @@ function admittedRecovery(
295
315
  if (!envelope) return { admitted: false, reason: "unsupported_envelope" };
296
316
  const admission = recoveryAdmission(req, config);
297
317
  if (!admission) return { admitted: false, reason: "admission_denied" };
318
+ // A JSON-encoded fixed-order tuple, not a delimiter-joined string: a field that carries the
319
+ // delimiter shifts every boundary after it, so two envelopes could hash to one entry and one
320
+ // recovery would replay for the other. A FINAL_ANSWER that omits its Task name line carries no
321
+ // addressing in the header, which leaves the structured recipient as the only field separating
322
+ // two such envelopes and makes the boundary the whole difference.
298
323
  const cacheKey = createHash("sha256")
299
- .update(admission.cacheScope)
300
- .update("\0")
301
- .update(parentThreadId ?? "")
302
- .update("\0")
303
- .update(envelope.messageType)
304
- .update("\0")
305
- .update(envelope.taskName)
306
- .update("\0")
307
- .update(envelope.sender)
308
- .update("\0")
309
- .update(JSON.stringify(envelope.ciphertexts))
324
+ .update(JSON.stringify([
325
+ admission.cacheScope,
326
+ parentThreadId ?? "",
327
+ envelope.messageType,
328
+ envelope.taskName ?? "",
329
+ envelope.recipient,
330
+ envelope.sender,
331
+ envelope.ciphertexts,
332
+ ]))
310
333
  .digest("hex");
311
334
  return { admitted: true, recovery: { envelope, admission, cacheKey } };
312
335
  }
@@ -11,7 +11,7 @@ import type { CodexWsSession } from "./codex-ws-session";
11
11
  import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
12
12
  MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
13
13
  codexWsCreateFrameExceedsLimit, codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion,
14
- type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire";
14
+ markCodexWsSocketDeath, type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire";
15
15
 
16
16
  interface ExchangeOptions {
17
17
  nativeControl?: NativeResponseControl;
@@ -213,14 +213,14 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
213
213
  resolve(response);
214
214
  };
215
215
 
216
- const failStream = (error: unknown, status: 502 | 504 = 502) => {
216
+ const failStream = (error: unknown, status: 502 | 504 = 502, { socketDied = false } = {}) => {
217
217
  if (terminal) return;
218
218
  terminal = true;
219
219
  if (sent && !responseCommitted && metadata) {
220
220
  // Nothing has been promised to the client yet, so the honest answer is a gateway
221
221
  // status, not a 200 whose body then fails. The frame may already be executing
222
- // upstream: the response is marked non-replayable so no layer of this process sends
223
- // it again, and the client applies its own retry policy as it would on the direct
222
+ // upstream: the response is marked non-replayable so no retry layer of this process
223
+ // sends it again, and the client applies its own retry policy as it would on the direct
224
224
  // path. Same settle order as a refused create: snapshot, detach, close, dispose.
225
225
  const prelude = metadata.snapshot();
226
226
  // Claim the commit slot so no later path can resolve a second, 200 Response.
@@ -230,7 +230,13 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
230
230
  session.dispose();
231
231
  const message = error instanceof Error ? error.message : String(error);
232
232
  const failureResponse = codexWsPreResponseFailure(status, message, prelude);
233
- markCodexWsStage(failureResponse, stageRecord(Buffer.byteLength(frameText, "utf8")));
233
+ const stage = failureStage();
234
+ markCodexWsStage(failureResponse, stageRecord(stage.requestBytes));
235
+ // #4191: a socket that died under the send is the one settle the dispatch may replace,
236
+ // once, and only under the operator's `retryOnReset` grant. Native steering and injection
237
+ // are left out: their channel may already have sent continuation frames on this socket, so
238
+ // the create frame alone no longer describes the turn.
239
+ if (socketDied && !nativeControl) markCodexWsSocketDeath(failureResponse, stage);
234
240
  resolve(failureResponse);
235
241
  return;
236
242
  }
@@ -549,7 +555,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
549
555
  resolve(sseFallback(url, init));
550
556
  return;
551
557
  }
552
- if (sent && !terminal) failStream(closedBeforeTerminalMessage(event, failureStage()));
558
+ if (sent && !terminal) {
559
+ failStream(closedBeforeTerminalMessage(event, failureStage()), 502, { socketDied: true });
560
+ }
553
561
  };
554
562
 
555
563
  const onError = () => {
@@ -560,7 +568,10 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
560
568
  cleanup();
561
569
  session.dispose();
562
570
  resolve(sseFallback(url, init));
563
- } else failStream(`codex websocket transport error${codexWsFailureDetail(failureStage())}`);
571
+ } else {
572
+ const message = `codex websocket transport error${codexWsFailureDetail(failureStage())}`;
573
+ failStream(message, 502, { socketDied: true });
574
+ }
564
575
  };
565
576
  detachOwner = session.bindOwner(reason => cancelExchange(reason));
566
577
  ws.addEventListener("open", onOpen);
@@ -214,10 +214,10 @@ export function classifyCodexWsFailure(stage: CodexWsFailureStage): CodexWsFailu
214
214
  * whose failures cannot be compared with anything else, which is the reported symptom -- every
215
215
  * such failure reached the user as one of two bare sentences.
216
216
  *
217
- * It does not relax the transport's own rule. The no-replay-after-send contract in
218
- * `codex-ws-exchange.ts` holds regardless of what this returns, and the stage below is
219
- * deliberately not consulted as a fallback-eligibility signal; it reports where the exchange got
220
- * to, and `resendPermission` happens to agree that everything past `before-send` is refused.
217
+ * It does not relax the transport's own rule. The stage below reports where the exchange got to,
218
+ * and `resendPermission` agrees that everything past `before-send` is refused. When a socket dies
219
+ * under the send, this stage is what the resend gate is asked with (#4191), so the operator's
220
+ * `retryOnReset` grant is the only way past that refusal, as it is for an HTTP reset.
221
221
  */
222
222
  export const CODEX_WS_FAILURE_PROJECTION = {
223
223
  /** The create frame never left, so the origin provably never saw this turn. */
@@ -236,6 +236,29 @@ export function projectCodexWsFailure(
236
236
  return CODEX_WS_FAILURE_PROJECTION[classifyCodexWsFailure(stage)];
237
237
  }
238
238
 
239
+ const socketDeathStages = new WeakMap<Response, RequestFailureStage>();
240
+
241
+ /**
242
+ * Record that a pre-response settle came from the socket closing or failing under the send (#4191),
243
+ * rather than from silence, a refused frame or a local limit.
244
+ *
245
+ * A fact about how the exchange ended, not a grant. The settle is the same non-replayable 502
246
+ * either way; whether the turn may go out once more is the resend gate's question, and only the
247
+ * operator's `retryOnReset` grant can answer it yes.
248
+ */
249
+ export function markCodexWsSocketDeath(response: Response, stage: CodexWsFailureStage): void {
250
+ socketDeathStages.set(response, projectCodexWsFailure(stage).stage);
251
+ }
252
+
253
+ /**
254
+ * Where the send stood when its socket died: `pre-header` when nothing came back and
255
+ * `protocol-prelude` when frames arrived but none was a Responses event. Undefined for every other
256
+ * response.
257
+ */
258
+ export function codexWsSocketDeathStage(response: Response): RequestFailureStage | undefined {
259
+ return socketDeathStages.get(response);
260
+ }
261
+
239
262
  /**
240
263
  * Render the stage as a suffix appended to an existing failure message.
241
264
  *
@@ -27,6 +27,7 @@ import {
27
27
  comboFailureCooldownScope,
28
28
  } from "../../combos";
29
29
  import { formatErrorResponse } from "../../bridge";
30
+ import { SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors";
30
31
  import {
31
32
  expandPreviousResponseInput,
32
33
  previousResponseReplayFailure,
@@ -73,6 +74,7 @@ import {
73
74
  import { preflightComboStreamResponse } from "./combo-stream-preflight";
74
75
  import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
75
76
  import { mandatoryResponsesReasoningReplayUnavailable } from "./core-replay";
77
+ import { settleOperatorReplacement } from "../../lib/upstream-retry";
76
78
 
77
79
  /**
78
80
  * Sends one combo target may run on its own before the ladder moves on. A target is a whole
@@ -165,6 +167,7 @@ export function comboTargetSendBudget(
165
167
  }
166
168
 
167
169
 
170
+ /** Dispatch a Responses combo within its shared send budget and preserve terminal child failures. */
168
171
  export async function executeComboResponses(
169
172
  req: Request,
170
173
  rawBody: unknown,
@@ -449,13 +452,17 @@ export async function executeComboResponses(
449
452
  countedExternally: true,
450
453
  });
451
454
  if (hopDecision && hopDecision.allowed) hopDecision.permit.use();
452
- else if (hopDecision && !firstComboTarget) {
455
+ else if (hopDecision && firstComboTarget) {
456
+ // A refused initial reservation authorizes no child send and has no upstream failure to return.
457
+ return formatErrorResponse(429, SEND_BUDGET_EXHAUSTED_CODE, "request send budget exhausted before combo dispatch");
458
+ }
459
+ else if (hopDecision) {
453
460
  // Out of budget is not this target's failure. The established exhaustion contract is to
454
461
  // return the last real upstream answer with its status, headers and any quota body
455
462
  // intact rather than to mint a synthetic error, and a later target only exists because
456
463
  // an earlier one already recorded one.
457
464
  if (lastFailedChildLog) adoptFailedChildLog(lastFailedChildLog);
458
- break;
465
+ return lastFailure!;
459
466
  }
460
467
  const targetSendBudget = comboSendScope
461
468
  ? comboTargetSendBudget(comboSendScope, combo.targets.length - 1 - comboTargetsDispatched)
@@ -678,9 +685,20 @@ export async function executeComboResponses(
678
685
  attemptRetained = true;
679
686
  lastFailure = failure.response;
680
687
  lastFailedChildLog = childLog;
688
+ // A replacement that answers 200 is unmarked, and its zero-output failure only exists once
689
+ // preflight has rebuilt the stream as a fresh Response. A spent grant never hops: a status the
690
+ // client would resend becomes the refusal, and anything else reaches the client as it is.
691
+ const spentReplacement = !failure.nonReplayable && comboSendScope?.ambiguousResendSpent === true;
692
+ if (spentReplacement) {
693
+ const settled = settleOperatorReplacement(failure.response);
694
+ if (settled !== failure.response) {
695
+ adoptFailedChildLog(childLog);
696
+ return settled;
697
+ }
698
+ }
681
699
  // A non-replayable failure (the answer to a spent ambiguous-reset replacement) may follow a
682
700
  // send that already ran the turn, so no later target may receive it, whatever its status says.
683
- const failureDecision = failure.nonReplayable
701
+ const failureDecision = failure.nonReplayable || spentReplacement
684
702
  ? "stop"
685
703
  : comboFailureDecision(failure.response.status, failure.classificationText, {
686
704
  code: failure.upstreamCode,
@@ -19,6 +19,7 @@ import { shouldPreparePlaintextV2AgentMessages } from "../../responses/plaintext
19
19
  import { hasValidatedActiveReasoningEffort } from "../../responses/parser";
20
20
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
21
21
  import { applyOpenAiVirtualModel } from "../../providers/openai-virtual-models";
22
+ import { renameRoutedIdentityInContext } from "../../adapters/identity";
22
23
  import {
23
24
  fastPolicyForModel,
24
25
  serviceTierSupportFromPolicy,
@@ -137,6 +138,12 @@ export async function applyFinalRouteRequestNormalization(args: {
137
138
  }
138
139
  parsed.modelId = route.modelId;
139
140
  }
141
+ // #5221: the parser named the identity sentence from the CLIENT selector, because routing had
142
+ // not run when it read the body, and only the adapters that build their own system text rename
143
+ // it afterwards. Settle it on the id this request really sends, here where that id is final —
144
+ // every dispatch path (passthrough, runTurn, adapter request build) reads the context after
145
+ // this, and a combo child runs this for its own target.
146
+ parsed.context = renameRoutedIdentityInContext(parsed.context, route.modelId);
140
147
  // Transport-neutral reliability policy (#875): applies to any Responses
141
148
  // upstream whose final adapter is openai-responses, not only WS turns.
142
149
  const responsesUpstreamStreaming = route.staticPolicy.model.responsesUpstreamStreaming;
@@ -311,14 +311,15 @@ export function shouldAttemptOpaqueBlobRecovery(args: {
311
311
  * Peek the upstream error body for the reasoning-effort downgrade. Only 400/403 are considered
312
312
  * and the body must be complete and display-safe, the same contract the other rejection peeks
313
313
  * use. The match is deliberately narrow: the upstream has to name reasoning effort, so an
314
- * unrelated 400 never triggers a replay.
314
+ * unrelated 400 never triggers a replay. A non-replayable answer, such as one to a spent operator
315
+ * replacement, is never read: the first send may already have run the turn.
315
316
  */
316
317
  export async function reasoningEffortRejectionText(
317
318
  response: Response,
318
319
  alreadyAttempted: boolean,
319
320
  signal: AbortSignal,
320
321
  ): Promise<string | undefined> {
321
- if (alreadyAttempted) return undefined;
322
+ if (alreadyAttempted || isNonReplayableResponse(response)) return undefined;
322
323
  if (response.status !== 400 && response.status !== 403) return undefined;
323
324
  try {
324
325
  const body = await readBoundedResponseBody(response.clone(), { signal });
@@ -198,21 +198,21 @@ function textWithoutFernetRuns(payload: string, runs: readonly FernetTokenRun[])
198
198
  /**
199
199
  * The routing header codex-rs writes above a delegated agent payload.
200
200
  *
201
- * `MESSAGE` is matched as well as `NEW_TASK`, and only for the unreadability CHECK --
202
- * recovery stays NEW_TASK-only. #3021 reported a subagent `MESSAGE` arriving in the
203
- * parent conversation as raw `gAAAA...` ciphertext after an `adapter_eof`. The detector
204
- * decides "unreadable" by stripping the envelope and asking whether any plaintext
205
- * survives, so an envelope shape it does not recognise counts as surviving text: a
206
- * `MESSAGE` whose entire body is one Fernet token measured as READABLE and was forwarded
207
- * verbatim.
201
+ * All four codex-rs message types are recognised: NEW_TASK, MESSAGE, FOLLOWUP_TASK,
202
+ * and FINAL_ANSWER, whose Task name line is optional. #3021 reported a subagent
203
+ * `MESSAGE` arriving in the parent conversation as raw `gAAAA...` ciphertext after an
204
+ * `adapter_eof`. The detector decides "unreadable" by stripping the envelope and asking
205
+ * whether any plaintext survives, so an envelope shape it does not recognise counts as
206
+ * surviving text: an unrecognised type whose entire body is one Fernet token measured as
207
+ * READABLE and would be forwarded verbatim.
208
208
  *
209
- * Widening the strip is not the same as widening recovery. Recovery decrypts, and
210
- * decrypting a `MESSAGE` on the parent's behalf would build a plaintext oracle out of a
211
- * payload the parent's session may have no right to read. This only lets the proxy
212
- * NOTICE that what it is about to forward is unreadable ciphertext, which is what the
213
- * report asks for: fail closed with a structured error rather than paste the token.
209
+ * This strip must therefore stay in step with the message types the opt-in recovery
210
+ * recognises (see agent-task-recovery.ts). The strip itself is still only detection: it
211
+ * lets the proxy notice that what it is about to forward is unreadable ciphertext and
212
+ * fail closed with a structured error rather than paste the token. Recovery admission,
213
+ * not the strip, is the trust boundary for decryption.
214
214
  */
215
- export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE)[^\n]*\nTask name\s*:[^\n]*\nSender\s*:[^\n]*\nPayload\s*:\s*(?:\n|$)/gi;
215
+ export const AGENT_MESSAGE_ROUTING_ENVELOPE = /(?:^|\n)Message Type\s*:\s*(?:NEW_TASK|MESSAGE|FOLLOWUP_TASK)[^\n]*\n\s*Task name\s*:[^\n]*\n\s*Sender\s*:[^\n]*\n\s*Payload\s*:\s*(?:\n|$)|(?:^|\n)Message Type\s*:\s*FINAL_ANSWER[^\n]*\n\s*(?:Task name\s*:[^\n]*\n\s*)?Sender\s*:[^\n]*\n\s*Payload\s*:\s*(?:\n|$)/gi;
216
216
 
217
217
  // CXC is the compatibility-hook control namespace. Strip only the tagged paragraph:
218
218
  // later untagged paragraphs may be genuine task text. Repeated CXC paragraphs are
@@ -262,7 +262,8 @@ function splitFernetParts(content: unknown[]): Set<object> {
262
262
  export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
263
263
  if (!Array.isArray(input)) return false;
264
264
 
265
- // codex-rs appends one NEW_TASK agent_message at the current input tail. Historical
265
+ // codex-rs appends one agent_message (any of the four codex-rs message types) at the
266
+ // current input tail. Historical
266
267
  // agent messages may be adjacent in full-history bodies; they must not poison the
267
268
  // later task. compaction_trigger/additional_tools are trailing metadata rather than
268
269
  // a newer user turn.
@@ -268,7 +268,7 @@ export function providerFetch(
268
268
  // transport (measured ~3s faster TTFT than the SSE POST queue); everything
269
269
  // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
270
270
  const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
271
- const upstreamWebsocket = provider.upstreamWebsocket === true;
271
+ const upstreamWebsocket = provider.upstreamWebsocket;
272
272
  if (!options.httpOnly && typeof input === "string" && init
273
273
  && shouldUseCodexWsUpstream(input, init, runtime, upstreamWebsocket)) {
274
274
  const egress = egressFor(input);