@bitkyc08/opencodex 2.82.0 → 2.84.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 (266) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/cursor/catalog.ts +2 -2
  10. package/src/adapters/cursor/effort-map.ts +1 -1
  11. package/src/adapters/devin/live-models.ts +2 -2
  12. package/src/adapters/google-truncation.ts +22 -0
  13. package/src/adapters/google.ts +7 -3
  14. package/src/adapters/ollama-native.ts +47 -15
  15. package/src/adapters/openai-chat/messages.ts +8 -4
  16. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  17. package/src/adapters/openai-responses/passthrough.ts +5 -0
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/request-strips.ts +10 -2
  21. package/src/adapters/qoder/adapter.ts +16 -14
  22. package/src/bridge/errors.ts +26 -1
  23. package/src/bridge.ts +1 -1
  24. package/src/claude/context-windows.ts +57 -4
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/intercept/connect-proxy.ts +10 -3
  27. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  28. package/src/claude/intercept/picker-listener.ts +49 -1
  29. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  30. package/src/claude/intercept/settings.ts +101 -9
  31. package/src/cli/account-api.ts +65 -0
  32. package/src/cli/account-auth.ts +44 -12
  33. package/src/cli/account.ts +41 -5
  34. package/src/cli/capabilities-access-remote.ts +8 -0
  35. package/src/cli/capabilities-base.ts +13 -1
  36. package/src/cli/catalog-command-result.ts +2 -1
  37. package/src/cli/claude.ts +15 -6
  38. package/src/cli/config-command.ts +8 -2
  39. package/src/cli/debug.ts +8 -0
  40. package/src/cli/dispatch.ts +16 -3
  41. package/src/cli/export-command.ts +14 -1
  42. package/src/cli/hub-disable.ts +99 -0
  43. package/src/cli/hub.ts +4 -2
  44. package/src/cli/index.ts +13 -4
  45. package/src/cli/link-child.ts +104 -0
  46. package/src/cli/link-enrollment.ts +96 -0
  47. package/src/cli/link.ts +39 -24
  48. package/src/cli/observe-stream.ts +6 -1
  49. package/src/cli/observe.ts +10 -2
  50. package/src/cli/provider.ts +12 -0
  51. package/src/cli/registry.ts +5 -4
  52. package/src/cli/resolve.ts +1 -1
  53. package/src/cli/runtime-api.ts +27 -2
  54. package/src/cli/status.ts +104 -20
  55. package/src/client/connect.ts +39 -4
  56. package/src/client/hub-client.ts +14 -7
  57. package/src/client/hub-state.ts +25 -14
  58. package/src/client/link-home.ts +248 -0
  59. package/src/client/link-management-relay.ts +61 -0
  60. package/src/client/link-relay.ts +6 -1
  61. package/src/client/link-status.ts +14 -0
  62. package/src/client/machine-api.ts +14 -7
  63. package/src/client/machine-listener.ts +29 -4
  64. package/src/clients/config-export/model-metadata.ts +16 -0
  65. package/src/clients/config-export/zcode-store.ts +46 -11
  66. package/src/clients/config-export/zcode.ts +3 -5
  67. package/src/clients/config-export.ts +47 -17
  68. package/src/clients/omo-role-models.ts +6 -3
  69. package/src/codex/agent-role-models.ts +26 -8
  70. package/src/codex/auth-api/account-list.ts +17 -2
  71. package/src/codex/auth-api/account-selection.ts +17 -7
  72. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  73. package/src/codex/auth-api/routes.ts +5 -1
  74. package/src/codex/auth-context.ts +8 -3
  75. package/src/codex/autostart-health.ts +3 -1
  76. package/src/codex/catalog/bundled.ts +5 -1
  77. package/src/codex/catalog/effort.ts +2 -2
  78. package/src/codex/catalog/metadata.ts +4 -2
  79. package/src/codex/catalog/native-models.ts +17 -3
  80. package/src/codex/catalog/provider-models.ts +25 -0
  81. package/src/codex/desktop-app/darwin.ts +1 -1
  82. package/src/codex/desktop-app/handoff.ts +1 -1
  83. package/src/codex/desktop-app/linux.ts +1 -1
  84. package/src/codex/desktop-app/lock.ts +1 -1
  85. package/src/codex/desktop-app/types.ts +1 -1
  86. package/src/codex/desktop-app/windows.ts +1 -1
  87. package/src/codex/desktop-app-restart.ts +1 -1
  88. package/src/codex/main-account.ts +6 -7
  89. package/src/codex/native-profile-startup.ts +4 -1
  90. package/src/codex/prompt-journal.ts +20 -2
  91. package/src/codex/prompt-layers/encoding.ts +49 -0
  92. package/src/codex/prompt-layers/import-source.ts +30 -0
  93. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  94. package/src/codex/prompt-layers/toml-read.ts +52 -4
  95. package/src/codex/prompt-layers.ts +383 -20
  96. package/src/codex/prompt-lock-claim.ts +13 -3
  97. package/src/codex/prompt-lock-evidence.ts +3 -3
  98. package/src/codex/prompt-lock-owner.ts +14 -6
  99. package/src/codex/prompt-text-probe.ts +34 -5
  100. package/src/codex/quota.ts +13 -6
  101. package/src/codex/role-auto-assign.ts +2 -5
  102. package/src/codex/routing/active-account.ts +21 -0
  103. package/src/codex/routing.ts +1 -1
  104. package/src/codex/runtime.ts +196 -8
  105. package/src/codex/shim-overlay.ts +8 -4
  106. package/src/codex/shim.ts +1 -1
  107. package/src/combos/failover.ts +3 -0
  108. package/src/config/api-key-reconcile.ts +34 -0
  109. package/src/config/diagnostics.ts +16 -0
  110. package/src/config/feature-flags.ts +5 -0
  111. package/src/config/live-reconcile.ts +43 -29
  112. package/src/config/live-topology.ts +29 -0
  113. package/src/config/load-degrade.ts +2 -1
  114. package/src/config/paths.ts +3 -0
  115. package/src/config/salvage.ts +3 -1
  116. package/src/config/schema/config-schema.ts +2 -0
  117. package/src/generated/compatibility-version.json +373 -217
  118. package/src/grok/grpc-web.ts +23 -1
  119. package/src/grok/inject.ts +245 -221
  120. package/src/grok/reset-coupon-ledger.ts +293 -57
  121. package/src/grok/reset-coupons.ts +15 -6
  122. package/src/integrations/cursor-detect.ts +1 -1
  123. package/src/integrations/mutation-plan.ts +13 -7
  124. package/src/integrations/registry.ts +45 -11
  125. package/src/integrations/state.ts +19 -7
  126. package/src/integrations/target.ts +42 -5
  127. package/src/integrations/writer.ts +14 -6
  128. package/src/lib/app-owned-memory-stores.ts +4 -0
  129. package/src/lib/app-owned-memory.ts +11 -0
  130. package/src/lib/errors.ts +25 -0
  131. package/src/lib/json-byte-size.ts +31 -0
  132. package/src/lib/optional-shutdown-hooks.ts +35 -0
  133. package/src/lib/outbound-credential-registry.ts +210 -0
  134. package/src/lib/passive-read.ts +35 -0
  135. package/src/lib/redact.ts +2 -2
  136. package/src/lib/shadow-call.ts +44 -5
  137. package/src/link/codex-settings.ts +155 -0
  138. package/src/link/management-routes.ts +89 -0
  139. package/src/link/remote-probe.ts +38 -0
  140. package/src/link/ssh-argv.ts +26 -5
  141. package/src/link/ssh-runner.ts +58 -27
  142. package/src/link/status-projection.ts +22 -4
  143. package/src/link/supervisor.ts +144 -49
  144. package/src/link/tunnel-state.ts +11 -4
  145. package/src/oauth/anthropic-continuity.ts +4 -3
  146. package/src/oauth/anthropic-identity.ts +13 -2
  147. package/src/oauth/anthropic-model-quota.ts +13 -1
  148. package/src/oauth/anthropic-routing.ts +230 -23
  149. package/src/oauth/anthropic.ts +18 -4
  150. package/src/oauth/google-antigravity.ts +24 -7
  151. package/src/oauth/index.ts +25 -16
  152. package/src/oauth/kiro-account-identity.ts +106 -0
  153. package/src/oauth/kiro.ts +48 -5
  154. package/src/oauth/meta-muse-device.ts +1 -1
  155. package/src/oauth/plan.ts +29 -0
  156. package/src/oauth/store.ts +40 -3
  157. package/src/oauth/types.ts +3 -1
  158. package/src/plugins/loader.ts +11 -0
  159. package/src/providers/anthropic-billing-ownership.ts +74 -0
  160. package/src/providers/anthropic-billing-recovery.ts +31 -0
  161. package/src/providers/anthropic-billing-refusal.ts +55 -0
  162. package/src/providers/anthropic-fast.ts +1 -1
  163. package/src/providers/anthropic-reset-admission.ts +62 -0
  164. package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
  165. package/src/providers/api-key-resolve.ts +17 -2
  166. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  167. package/src/providers/devin-provider-merge-migration.ts +1 -1
  168. package/src/providers/github-copilot-auto.ts +169 -0
  169. package/src/providers/key-failover.ts +52 -5
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/model-rename-startup.ts +5 -3
  172. package/src/providers/muse-subscription-usage.ts +1 -1
  173. package/src/providers/quota/account-cache.ts +11 -0
  174. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  175. package/src/providers/quota/report-cache.ts +14 -0
  176. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  177. package/src/providers/quota.ts +4 -1
  178. package/src/providers/registry/entries-core.ts +20 -20
  179. package/src/providers/registry/entries-extended.ts +4 -1
  180. package/src/providers/registry/model-seeds.ts +20 -3
  181. package/src/providers/request-pacing.ts +9 -0
  182. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  183. package/src/providers/xai-fast-model.ts +1 -1
  184. package/src/responses/compaction.ts +14 -0
  185. package/src/responses/parser.ts +7 -1
  186. package/src/server/auth-cors.ts +42 -0
  187. package/src/server/configured-credentials.ts +194 -0
  188. package/src/server/inbound-body-admission.ts +24 -7
  189. package/src/server/index/link-listener.ts +30 -17
  190. package/src/server/index/link-management.ts +58 -0
  191. package/src/server/index/optional-listeners.ts +5 -1
  192. package/src/server/index/serve-options.ts +21 -2
  193. package/src/server/index.ts +1 -1
  194. package/src/server/lifecycle.ts +33 -5
  195. package/src/server/management/agent-settings-routes.ts +28 -10
  196. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  197. package/src/server/management/codex-agent-role-routes.ts +19 -6
  198. package/src/server/management/codex-prompt-routes.ts +136 -5
  199. package/src/server/management/codex-role-auto-assign.ts +1 -1
  200. package/src/server/management/grok-coupon-routes.ts +333 -57
  201. package/src/server/management/link-enrollment.ts +192 -0
  202. package/src/server/management/link-route-state.ts +185 -0
  203. package/src/server/management/link-routes.ts +92 -390
  204. package/src/server/management/model-rows.ts +3 -1
  205. package/src/server/management/provider-overwrite-carry.ts +10 -0
  206. package/src/server/management/provider-routes.ts +1 -0
  207. package/src/server/management/route-registry.ts +7 -5
  208. package/src/server/management/shared.ts +3 -0
  209. package/src/server/management-api.ts +3 -1
  210. package/src/server/messages-native.ts +68 -42
  211. package/src/server/relay-eager.ts +4 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-failure-attribution.ts +3 -0
  214. package/src/server/request-log-filter.ts +6 -1
  215. package/src/server/request-log-terminal-status.ts +243 -0
  216. package/src/server/request-log.ts +15 -118
  217. package/src/server/responses/adapter-continuation.ts +9 -1
  218. package/src/server/responses/adapter-dispatch.ts +293 -81
  219. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  220. package/src/server/responses/codex-ws-exchange.ts +147 -6
  221. package/src/server/responses/codex-ws-wire.ts +203 -0
  222. package/src/server/responses/collaboration.ts +36 -7
  223. package/src/server/responses/compact.ts +129 -50
  224. package/src/server/responses/context-overflow.ts +13 -1
  225. package/src/server/responses/core-codex-account.ts +105 -1
  226. package/src/server/responses/core-combo-failure.ts +14 -0
  227. package/src/server/responses/core-combo.ts +7 -2
  228. package/src/server/responses/core-errors.ts +29 -1
  229. package/src/server/responses/core-opaque-recovery.ts +5 -5
  230. package/src/server/responses/core-options.ts +2 -0
  231. package/src/server/responses/core.ts +6 -6
  232. package/src/server/responses/deferred-collab-surface.ts +158 -0
  233. package/src/server/responses/deferred-send-reporter.ts +25 -0
  234. package/src/server/responses/empty-completion-guard.ts +18 -2
  235. package/src/server/responses/fetch-helpers.ts +12 -1
  236. package/src/server/responses/jev-model-invoke.ts +1 -1
  237. package/src/server/responses/passthrough-delivery.ts +4 -1
  238. package/src/server/responses/passthrough-dispatch.ts +130 -64
  239. package/src/server/responses/passthrough-execution.ts +3 -3
  240. package/src/server/responses/request-prepare.ts +6 -4
  241. package/src/server/responses/request-transport.ts +131 -13
  242. package/src/server/responses/run-turn-execution.ts +13 -5
  243. package/src/server/responses/sidecar-execution.ts +16 -2
  244. package/src/server/responses/spend-storage-error.ts +33 -0
  245. package/src/server/responses/ws-upstream.ts +9 -1
  246. package/src/server/startup-action-control.ts +22 -2
  247. package/src/server/startup-health-cache.ts +4 -0
  248. package/src/server/system-env-shell.ts +7 -2
  249. package/src/server/system-env.ts +11 -1
  250. package/src/service/cli.ts +5 -1
  251. package/src/service/desktop-startup.ts +147 -34
  252. package/src/service/desktop-supervision.d.mts +27 -1
  253. package/src/service/desktop-supervision.mjs +111 -5
  254. package/src/service/health-sentinel-launch.ts +176 -0
  255. package/src/service/health-sentinel-main.ts +447 -0
  256. package/src/service/health-sentinel-policy.ts +241 -0
  257. package/src/service/health-sentinel-probe.ts +54 -0
  258. package/src/service/health-sentinel-windows.ts +87 -0
  259. package/src/service/linux-linger.ts +20 -0
  260. package/src/types/config.ts +5 -0
  261. package/src/types/request.ts +5 -0
  262. package/src/usage/expected-prices.ts +5 -1
  263. package/src/usage/log.ts +19 -1
  264. package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
  265. package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
  266. package/gui/dist/assets/index-D3FBCNiP.js +0 -94
@@ -22,7 +22,15 @@ import { classifyPoolRecoveryDispatch } from "../../routing/probe-lease";
22
22
  import { formatErrorResponse } from "../../bridge";
23
23
  import { readBoundedResponseBody } from "../../lib/bounded-body";
24
24
  import { upstreamErrorMessageFromPayload, isRateLimitOrQuotaFailureMessage } from "../../lib/errors";
25
- import { isNonReplayableResponse, isTransientUpstreamStatus } from "../../lib/upstream-retry";
25
+ import { isNonReplayableResponse, isTransientUpstreamStatus, SendBudgetExhaustedError } from "../../lib/upstream-retry";
26
+ import {
27
+ codexWsPreOutputRefusalHold,
28
+ codexWsPreOutputRefusalOutcomeRecorded,
29
+ isCodexWsPreOutputRefusal,
30
+ noteCodexWsPreOutputRefusalOutcome,
31
+ restoreCodexWsPreOutputRefusal,
32
+ type CodexWsPreOutputRefusalHold,
33
+ } from "./codex-ws-wire";
26
34
  import type { RequestLogContext } from "../request-log";
27
35
  import type { DataPlaneAdmission } from "../auth-cors";
28
36
  import type { InboundWire } from "../../providers/registry";
@@ -121,6 +129,90 @@ export function usesCodexForwardPoolAuth(
121
129
  && provider.authMode === "forward" && provider.adapter === "openai-responses";
122
130
  }
123
131
 
132
+ /**
133
+ * Write a pre-output refusal projection's account outcome once (#6969), for an exit that no other
134
+ * owner records: a caller abort or a local send-budget refusal during the same-account backoff,
135
+ * where the transport-failure path writes no account health and the restored stream never reaches
136
+ * delivery. A projection already recorded by the alternate-account helper is left alone.
137
+ */
138
+ export function recordCodexPreOutputRefusalOutcome(
139
+ config: OcxConfig,
140
+ authCtx: CodexAuthContext,
141
+ provider: OcxProviderConfig,
142
+ response: Response,
143
+ modelId: string,
144
+ ): void {
145
+ if (!isCodexWsPreOutputRefusal(response) || codexWsPreOutputRefusalOutcomeRecorded(response)) return;
146
+ if (!usesCodexForwardPoolAuth(authCtx, provider)) return;
147
+ noteCodexWsPreOutputRefusalOutcome(response);
148
+ recordCodexUpstreamOutcome(config, authCtx.accountId, response.status, {
149
+ threadId: authCtx.affinityKey,
150
+ fixedAccount: authCtx.fixedAccount,
151
+ modelId,
152
+ probeLeaseId: codexProbeLeaseId(authCtx),
153
+ probeQuotaScope: codexProbeQuotaScope(authCtx),
154
+ transientProbe: codexTransientProbeGrant(authCtx),
155
+ writerGeneration: authCtx.writerGeneration,
156
+ });
157
+ }
158
+
159
+ /**
160
+ * The pre-output refusal hold for one Pool send starting now (#6969), or undefined. Pool forward
161
+ * sends only, and never a combo child, whose own stream preflight already advances on a zero-output
162
+ * terminal. Every projection a send produces must later pass through
163
+ * {@link PoolPreOutputRefusals.restore}.
164
+ */
165
+ export function poolPreOutputRefusalHold(
166
+ authCtx: CodexAuthContext,
167
+ provider: OcxProviderConfig,
168
+ connectMs: number,
169
+ comboAttempt: boolean | undefined,
170
+ ): CodexWsPreOutputRefusalHold | undefined {
171
+ return !comboAttempt && usesCodexForwardPoolAuth(authCtx, provider) ? codexWsPreOutputRefusalHold(connectMs) : undefined;
172
+ }
173
+
174
+ /**
175
+ * One dispatch's view of its pre-output refusal projections (#6969): the last one the initial
176
+ * transient ladder observed, the single exit where the dispatch itself must record it, and the
177
+ * restore that hands the client the original stream.
178
+ */
179
+ export class PoolPreOutputRefusals {
180
+ private last: Response | undefined;
181
+ /** True when the response handed to delivery is a restored projection already recorded. */
182
+ outcomeRecorded = false;
183
+
184
+ readonly observe = (response: Response): Response => {
185
+ if (isCodexWsPreOutputRefusal(response)) this.last = response;
186
+ return response;
187
+ };
188
+
189
+ /**
190
+ * One owner per exceptional exit of the initial ladder. A caller abort or a local budget refusal
191
+ * during the backoff leaves the transport-failure path writing no account health, so the refusal
192
+ * already observed is recorded here; any other exception keeps that path as its single writer.
193
+ */
194
+ recordOnLocalExit(
195
+ error: unknown,
196
+ aborted: boolean,
197
+ config: OcxConfig,
198
+ authCtx: CodexAuthContext,
199
+ provider: OcxProviderConfig,
200
+ modelId: string,
201
+ ): void {
202
+ if (!this.last || !(aborted || error instanceof SendBudgetExhaustedError)) return;
203
+ recordCodexPreOutputRefusalOutcome(config, authCtx, provider, this.last, modelId);
204
+ }
205
+
206
+ /**
207
+ * Called right after the Pool block on every recovery pass: a projection that survived the ladder
208
+ * and the account move becomes the original event stream before any later reader sees it.
209
+ */
210
+ restore(response: Response): Response {
211
+ this.outcomeRecorded = isCodexWsPreOutputRefusal(response) && codexWsPreOutputRefusalOutcomeRecorded(response);
212
+ return restoreCodexWsPreOutputRefusal(response);
213
+ }
214
+ }
215
+
124
216
 
125
217
  /** Live proof that this plain-main response used the observed main credential. */
126
218
  export function liveMainQuotaDispatch(
@@ -368,6 +460,10 @@ export async function shouldRetryCodexScopedQuotaOnAlternate(
368
460
  * could duplicate a turn the origin may still be running. A 5xx whose body confirms quota is not
369
461
  * routed here either — {@link shouldRetryCodexPoolAccountQuota} classifies that one first and
370
462
  * carries the cooldown with it.
463
+ *
464
+ * A WebSocket refusal reaches this predicate only as the opt-in pre-output projection (#6969): the
465
+ * upstream itself ended the turn with a structured 5xx while nothing but empty lifecycle frames had
466
+ * been relayed. It carries no non-replayable marker because nothing could have been observed.
371
467
  */
372
468
  export function shouldRetryCodexPoolAccountTransient(response: Response): boolean {
373
469
  return !isNonReplayableResponse(response) && isTransientUpstreamStatus(response.status);
@@ -420,6 +516,8 @@ export interface CodexPoolAccountRetryArgs {
420
516
  stream: boolean;
421
517
  /** Keep a buffered canonical client on HTTP/SSE after moving to another Pool account. */
422
518
  httpOnly?: boolean;
519
+ /** Pre-output refusal hold for the alternate's WebSocket send (#6969); the caller restores. */
520
+ preOutputRefusalHold?: CodexWsPreOutputRefusalHold;
423
521
  onResponse?: (
424
522
  response: Response,
425
523
  authCtx: CodexAuthContext,
@@ -571,6 +669,10 @@ export async function retryCodexPoolOnAlternateAccount(
571
669
  // keep handing it the next request.
572
670
  const recordUnmovedTransientOutcome = (): void => {
573
671
  if (!isTransientUpstreamStatus(outcomeStatus)) return;
672
+ // #6969: one write per projection. After recordFirstOutcome, a late window or permit denial
673
+ // must not record the same refusal again, and a recorded projection tells delivery to skip it.
674
+ if (codexWsPreOutputRefusalOutcomeRecorded(firstResponse)) return;
675
+ noteCodexWsPreOutputRefusalOutcome(firstResponse);
574
676
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
575
677
  threadId: firstAuthCtx.affinityKey,
576
678
  fixedAccount: firstAuthCtx.fixedAccount,
@@ -763,6 +865,7 @@ export async function retryCodexPoolOnAlternateAccount(
763
865
  // Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
764
866
  ...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
765
867
  });
868
+ noteCodexWsPreOutputRefusalOutcome(firstResponse);
766
869
  };
767
870
  // Only a combo reset-derived outcome is deferred. Retry-After, defaults, and
768
871
  // ordinary requests must block the first account before the alternate send.
@@ -918,6 +1021,7 @@ export async function retryCodexPoolOnAlternateAccount(
918
1021
  httpOnly,
919
1022
  providerName: route.providerName,
920
1023
  modelId: route.modelId,
1024
+ preOutputRefusalHold: args.preOutputRefusalHold,
921
1025
  onCodexWsQuota: codexWsQuotaObserver(retryAuthCtx, route.provider, route.modelId),
922
1026
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
923
1027
  ? createCodexAuthDispatchGuard(retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
@@ -14,6 +14,7 @@ import {
14
14
  import { normalizeUpstreamErrorText } from "./core-errors";
15
15
  import { resolveClientRetryAfter } from "../../lib/retry-after";
16
16
  import { formatErrorResponse } from "../../bridge";
17
+ import { OLLAMA_LATE_ATTRIBUTION_LIMIT_MESSAGE } from "../../adapters/ollama-native";
17
18
  import { isNonReplayableResponse, markResponseNonReplayable } from "../../lib/upstream-retry";
18
19
  import { usageFromResponsesPayload } from "../request-log";
19
20
  import type { ResponsesTerminalStatus } from "../../bridge";
@@ -121,6 +122,7 @@ export async function consumeComboFailure(
121
122
  classificationText,
122
123
  ...(codexModelRefusal !== undefined ? { codexModelRefusal } : {}),
123
124
  ...(normalizedUpstreamCode !== undefined ? { upstreamCode: normalizedUpstreamCode } : {}),
125
+ ...(upstreamMessage !== undefined ? { upstreamMessage } : {}),
124
126
  ...(upstreamType !== undefined ? { upstreamType } : {}),
125
127
  ...(!cyberFailure && cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}),
126
128
  // The EFFECTIVE classification decides, not the raw status. An upstream that wraps a quota
@@ -221,3 +223,15 @@ export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
221
223
  childHeaders.delete("content-encoding");
222
224
  return childHeaders;
223
225
  }
226
+
227
+ /**
228
+ * A member's local Ollama late-attribution refusal: the exact proxy-owned constant on the exact
229
+ * local wire shape, from a replayable attempt. The caller replaces only the client response with
230
+ * the literal constant envelope; classification inputs are never rewritten.
231
+ */
232
+ export function isLateAttributionRefusal(failure: ConsumedComboFailure): boolean {
233
+ return !failure.nonReplayable
234
+ && failure.response.status === 413
235
+ && failure.upstreamCode === "request_too_large"
236
+ && failure.upstreamMessage === OLLAMA_LATE_ATTRIBUTION_LIMIT_MESSAGE;
237
+ }
@@ -80,6 +80,7 @@ import {
80
80
  buildComboChildHeaders,
81
81
  createChildPassthroughCallbackGate,
82
82
  consumeComboFailure,
83
+ isLateAttributionRefusal,
83
84
  } from "./core-combo-failure";
84
85
  import {
85
86
  linkRequestSessionLane,
@@ -100,7 +101,7 @@ import {
100
101
  markEagerRelaySseResponse,
101
102
  } from "../relay";
102
103
  import { preflightComboStreamResponse } from "./combo-stream-preflight";
103
- import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
104
+ import { streamingContextOverflowResponse, jsonContextOverflowResponse, lateAttributionLimitResponse } from "./context-overflow";
104
105
  import { mandatoryResponsesReasoningReplayUnavailable } from "./core-replay";
105
106
  import { settleOperatorReplacement } from "../../lib/upstream-retry";
106
107
  import { createComboProtocolLanes, dispatchNativeComboChild } from "./core-combo-native";
@@ -1007,6 +1008,10 @@ export async function executeComboResponses(
1007
1008
  retainCancelledAttempt();
1008
1009
  return clientCancelledResponse();
1009
1010
  }
1011
+ // A member's local late-attribution refusal reaches the client only as the literal constant
1012
+ // envelope: swap the response, keep every classification input, and skip the overflow rewrite.
1013
+ const lateAttributionRefusal = isLateAttributionRefusal(failure);
1014
+ if (lateAttributionRefusal) failure = { ...failure, response: lateAttributionLimitResponse() };
1010
1015
  sealRequestAttemptIdentity(
1011
1016
  attempt,
1012
1017
  childLog.provider,
@@ -1047,7 +1052,7 @@ export async function executeComboResponses(
1047
1052
  });
1048
1053
  const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true;
1049
1054
  // Local byte admission has its own diagnostic; do not relabel it as an upstream refusal.
1050
- const classifyOverflow = failure.response.status === 413
1055
+ const classifyOverflow = failure.response.status === 413 && !lateAttributionRefusal
1051
1056
  && (wantsStream || (failure.upstreamCode !== "outbound_body_too_large"
1052
1057
  && failure.upstreamCode !== "translation_buffer_limit"));
1053
1058
  lastFailureClassifiesOverflow = classifyOverflow;
@@ -2,7 +2,7 @@ import { readBoundedResponseBody } from "../../lib/bounded-body";
2
2
  import { redactSecretString } from "../../lib/redact";
3
3
  import { isCyberPolicyMessage, isCyberPolicyCode } from "../../lib/errors";
4
4
  import { isTranslatorBudgetExceededError } from "../../lib/translator-budget";
5
- import { formatErrorResponse } from "../../bridge";
5
+ import { formatErrorResponse, formatUsageLimitReachedResponse } from "../../bridge";
6
6
  import {
7
7
  UnsupportedContentEncodingError,
8
8
  DecompressedBodyTooLargeError,
@@ -132,6 +132,34 @@ export function clientCancelledResponse(): Response {
132
132
  return formatErrorResponse(499, "client_cancelled", "Client cancelled request");
133
133
  }
134
134
 
135
+ /**
136
+ * The Responses answer to a local Anthropic pool refusal (`AnthropicAccountCooldownError`).
137
+ *
138
+ * When every held account is out on spent quota the refusal is a usage limit (#6991), which the
139
+ * Codex client stops on and renders with its reset time; anything else stays the retryable
140
+ * `rate_limit_error` it always was. A combo child keeps the retryable form and wording: the combo
141
+ * hops on any 429 and rebuilds the body anyway, and `isAnthropicPoolLocalRefusal` in
142
+ * `core-combo.ts` deliberately cools the target for the short local fallback, which a usage-limit
143
+ * code or usage-limit prose would stretch to the ten-minute exhaustion fallback in
144
+ * `combos/failover.ts`.
145
+ */
146
+ export function anthropicPoolCooldownResponse(
147
+ refusal: {
148
+ message: string;
149
+ retryableMessage: string;
150
+ retryAfterSeconds: number | null;
151
+ usageLimit: { readonly resetsAt: number | null } | null;
152
+ },
153
+ options: { comboAttempt?: boolean; logCtx?: { errorCode?: string } } = {},
154
+ ): Response {
155
+ if (refusal.usageLimit !== null && !options.comboAttempt) {
156
+ if (options.logCtx) options.logCtx.errorCode = "usage_limit_reached";
157
+ return formatUsageLimitReachedResponse(refusal.message, refusal.usageLimit.resetsAt, refusal.retryAfterSeconds);
158
+ }
159
+ return formatErrorResponse(429, "rate_limit_error", refusal.retryableMessage,
160
+ refusal.retryAfterSeconds === null ? undefined : { retryAfter: String(refusal.retryAfterSeconds) });
161
+ }
162
+
135
163
 
136
164
  export const UNREADABLE_ENCRYPTED_AGENT_TASK_MESSAGE =
137
165
  "Routed V2 worker task is encrypted for the native ChatGPT backend and cannot be read by the selected provider. Use plaintext V2 agent-message delivery or select a native ChatGPT model.";
@@ -465,13 +465,13 @@ export function resetStreamedOpaqueBlobLogContext(logCtx: RequestLogContext): vo
465
465
  export type OpaqueBlobRecoveryGuard = { attempted: boolean };
466
466
 
467
467
 
468
- export type OpaqueBlobRecoveryResult =
468
+ export type OpaqueBlobRecoveryResult<Failure = Response> =
469
469
  | { kind: "skipped" }
470
470
  | { kind: "recovered"; response: Response }
471
- | { kind: "failed"; response: Response };
471
+ | { kind: "failed"; response: Failure };
472
472
 
473
473
 
474
- export async function attemptOpaqueBlobRecovery(
474
+ export async function attemptOpaqueBlobRecovery<Failure = Response>(
475
475
  args: {
476
476
  response: Response;
477
477
  outboundBody?: string;
@@ -480,8 +480,8 @@ export async function attemptOpaqueBlobRecovery(
480
480
  guard: OpaqueBlobRecoveryGuard;
481
481
  signal: AbortSignal;
482
482
  },
483
- rebuild: (kind: AttemptRecoveryKind) => Promise<Response | { failed: Response }>,
484
- ): Promise<OpaqueBlobRecoveryResult> {
483
+ rebuild: (kind: AttemptRecoveryKind) => Promise<Response | { failed: Failure }>,
484
+ ): Promise<OpaqueBlobRecoveryResult<Failure>> {
485
485
  const errorBody = await opaqueBlobRejectionBodyForRecovery(
486
486
  args.response,
487
487
  args.outboundBody,
@@ -30,6 +30,8 @@ export interface ConsumedComboFailure {
30
30
  codexModelRefusal?: CodexAccountModelRefusal;
31
31
  /** Structured upstream `error.code` when present in the failure body. */
32
32
  upstreamCode?: string;
33
+ /** Sanitized structured `error.message`; compared only against proxy-owned constants, never emitted. */
34
+ upstreamMessage?: string;
33
35
  /** Complete structured provider type, retained for conservative recovery classification. */
34
36
  upstreamType?: string;
35
37
  /** Valid numeric/date value used only for cooldown calculation. */
@@ -28,7 +28,6 @@ import { deliverAdapterResponse } from "./adapter-delivery";
28
28
  import { releaseUpstreamHostAdmission } from "../../codex/upstream-host-health";
29
29
  import { releaseCodexAuthContextProbeLease } from "../../codex/auth-context";
30
30
  import { runWithCompactionRecovery } from "./compaction-recovery";
31
-
32
31
  /**
33
32
  * Route one `/v1/responses` request through the adapter pipeline: recovery loop, passthrough
34
33
  * wire, image/web-search bridges, and the terminal-guard continuation.
@@ -75,7 +74,6 @@ export async function handleResponses(
75
74
  if (!options.comboInitialSend?.producerOwned) options.comboInitialSend?.permit.release();
76
75
  }
77
76
  }
78
-
79
77
  export async function handleComboResponses(
80
78
  req: Request,
81
79
  rawBody: unknown,
@@ -94,7 +92,6 @@ export async function handleComboResponses(
94
92
  requestDispatchers,
95
93
  );
96
94
  }
97
-
98
95
  /** Compose request phases while retaining the original admission-finally ownership. */
99
96
  async function handleResponsesInner(
100
97
  req: Request,
@@ -107,6 +104,7 @@ async function handleResponsesInner(
107
104
  pendingHostAdmissionLease: null,
108
105
  authCtx: { kind: "main", accountId: null },
109
106
  };
107
+ let releasePendingSend = () => {};
110
108
  try {
111
109
  const requestState = await prepareResponsesRequest(requestContext, admissionState, requestDispatchers);
112
110
  if (requestState instanceof Response) return requestState;
@@ -124,7 +122,7 @@ async function handleResponsesInner(
124
122
  const sendBudgetState = createResponsesSendBudget(requestContext);
125
123
  if (sendBudgetState instanceof Response) return sendBudgetState;
126
124
  if ("passthrough" in transportState.adapter && transportState.adapter.passthrough && !sidecarState.routedCompaction) {
127
- return await executePassthroughResponse(
125
+ const passthroughResult = await executePassthroughResponse(
128
126
  requestContext,
129
127
  admissionState,
130
128
  requestState,
@@ -133,6 +131,9 @@ async function handleResponsesInner(
133
131
  responseEffects,
134
132
  sendBudgetState,
135
133
  );
134
+ if (passthroughResult instanceof Response) return passthroughResult;
135
+ const unclaimedHop = sendBudgetState.pendingHopPermit;
136
+ releasePendingSend = () => { if (sendBudgetState.pendingHopPermit === unclaimedHop) { unclaimedHop?.release(); sendBudgetState.pendingHopPermit = undefined; } };
136
137
  }
137
138
  const sidecarPlans = await executeResponsesSidecars(
138
139
  requestContext,
@@ -182,15 +183,14 @@ async function handleResponsesInner(
182
183
  continuationState,
183
184
  );
184
185
  } finally {
186
+ releasePendingSend();
185
187
  if (admissionState.pendingHostAdmissionLease) {
186
188
  releaseUpstreamHostAdmission(admissionState.pendingHostAdmissionLease);
187
189
  releaseCodexAuthContextProbeLease(admissionState.authCtx);
188
190
  }
189
191
  }
190
192
  }
191
-
192
193
  const requestDispatchers: ResponsesDispatchers = { handleResponses, handleComboResponses };
193
-
194
194
  export { adapterNeedsForcedContinuation } from "./core-replay";
195
195
  export {
196
196
  sidecarOutcomeRecorder, codexLogAccountId, usesCodexForwardPoolAuth, preAuthUpstreamHostCircuitKey,
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Deferred V1 collaboration evidence from Codex's `<tools>` world-state fragment (#6844).
3
+ *
4
+ * When the model supports tool search (every OpenCodex catalog row), codex-rs registers the V1
5
+ * collaboration tools as DEFERRED (core/src/tools/spec_plan.rs, add_collaboration_tools) under the
6
+ * fixed namespace `multi_agent_v1` (core/src/tools/handlers/multi_agents_spec.rs). Neither the wire
7
+ * tools nor the code-mode exec description then mention them. With the deferred_tool_world_state
8
+ * feature on (default off upstream; Codex Desktop sessions were observed receiving it), codex-rs
9
+ * instead tells the model which namespaces it can load, in a developer fragment
10
+ * (core/src/context/world_state/tools.rs):
11
+ *
12
+ * <tools>
13
+ * Deferred tool namespaces:
14
+ * - multi_agent_v1: Tools for spawning and managing sub-agents.
15
+ * </tools>
16
+ *
17
+ * Later turns carry only diffs ("Added deferred tool namespaces:", "Removed deferred tool
18
+ * namespaces:", "No deferred tool namespaces remain."), so membership is replayed in input order.
19
+ * The fragment is capped at 4 KiB: rows may be name-only, and whole rows may be replaced by
20
+ * "... <n> additional namespaces omitted." (tools_budget.rs).
21
+ *
22
+ * The evidence is positive-only and conservative:
23
+ * - only developer messages are read, each text part on its own, and only a part that is exactly
24
+ * one fragment after trimming; text that merely mentions the tag is ignored;
25
+ * - a fragment that starts like one but breaks the line grammar clears what was carried, because a
26
+ * lost removal could have been multi_agent_v1; omitted removals clear for the same reason, and
27
+ * omitted rows never establish a namespace;
28
+ * - it counts only beside a host that can load deferred tools: a genuine code-mode exec or a
29
+ * structured tool_search tool. A caller's own exec or a function merely named tool_search is not
30
+ * one.
31
+ *
32
+ * Capability boundary: the row proves that some deferred multi_agent_v1 tool is registered, not
33
+ * that spawn_agent is. Upstream can split them only through an embedder-supplied ToolPolicy
34
+ * allowlist; its one production allowlist (the guardian reviewer) admits no V1 tool at all, and
35
+ * the V1 depth limit disables the surface as a unit, so Codex CLI and Desktop never send a
36
+ * management-only V1 namespace. A developer item can also be impersonated by whoever writes the
37
+ * conversation. Both cases receive advisory guidance they cannot act on; nothing is enforced here.
38
+ */
39
+ import type { OcxMessage, OcxParsedRequest, OcxTool } from "../../types";
40
+ import type { CodeModeExecAnalysis } from "./code-mode-collab-surface";
41
+
42
+ const MULTI_AGENT_V1_NAMESPACE = "multi_agent_v1";
43
+ const OPEN_TAG = "<tools>";
44
+ const CLOSE_TAG = "</tools>";
45
+ const SNAPSHOT_HEADER = "Deferred tool namespaces:";
46
+ const ADDED_HEADER = "Added deferred tool namespaces:";
47
+ const REMOVED_HEADER = "Removed deferred tool namespaces:";
48
+ const EMPTY_STATE = "No deferred tool namespaces remain.";
49
+ // A description is opaque: upstream copies its first line verbatim, which may hold any character
50
+ // except \n, including U+2028/U+2029 that `.` would refuse.
51
+ const ROW = /^- ([^:\s]+)(?:: [\s\S]*)?$/;
52
+ const OMISSION = /^\.\.\. \d+ additional namespaces omitted\.$/;
53
+
54
+ type Fragment =
55
+ | { kind: "malformed" }
56
+ | { kind: "snapshot"; rows: Set<string>; empty: boolean }
57
+ | { kind: "diff"; added: Set<string>; removed: Set<string>; removedOmitted: boolean; empty: boolean };
58
+
59
+ /** One standalone fragment, a malformed one, or nothing (text that is not a fragment). */
60
+ function parseFragment(text: string): Fragment | null {
61
+ const trimmed = text.trim();
62
+ if (!trimmed.startsWith(OPEN_TAG)) return null;
63
+ if (!trimmed.endsWith(CLOSE_TAG) || trimmed.length < OPEN_TAG.length + CLOSE_TAG.length) return { kind: "malformed" };
64
+ const body = trimmed.slice(OPEN_TAG.length, trimmed.length - CLOSE_TAG.length);
65
+ let group: "snapshot" | "added" | "removed" | undefined;
66
+ // Upstream renders each group at most once, Added before Removed, and skips empty groups.
67
+ const seen = new Set<"snapshot" | "added" | "removed">();
68
+ let groupHasEntries = true;
69
+ let empty = false;
70
+ const rows = { snapshot: new Set<string>(), added: new Set<string>(), removed: new Set<string>() };
71
+ let removedOmitted = false;
72
+ const open = (next: "snapshot" | "added" | "removed"): boolean => {
73
+ if (seen.has(next) || !groupHasEntries) return false;
74
+ if (next === "added" && seen.has("removed")) return false;
75
+ seen.add(next);
76
+ group = next;
77
+ groupHasEntries = false;
78
+ return true;
79
+ };
80
+ for (const raw of body.split("\n")) {
81
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
82
+ if (line === "") continue;
83
+ // The empty-state notice closes the fragment; nothing may follow it.
84
+ if (empty) return { kind: "malformed" };
85
+ if (line === SNAPSHOT_HEADER) { if (!open("snapshot")) return { kind: "malformed" }; continue; }
86
+ if (line === ADDED_HEADER) { if (!open("added")) return { kind: "malformed" }; continue; }
87
+ if (line === REMOVED_HEADER) { if (!open("removed")) return { kind: "malformed" }; continue; }
88
+ if (line === EMPTY_STATE) {
89
+ if (!groupHasEntries) return { kind: "malformed" };
90
+ empty = true;
91
+ continue;
92
+ }
93
+ if (group === undefined) return { kind: "malformed" };
94
+ groupHasEntries = true;
95
+ if (OMISSION.test(line)) {
96
+ if (group === "removed") removedOmitted = true;
97
+ continue;
98
+ }
99
+ const row = ROW.exec(line);
100
+ if (!row) return { kind: "malformed" };
101
+ rows[group].add(row[1]!);
102
+ }
103
+ if (!groupHasEntries) return { kind: "malformed" };
104
+ const sawSnapshot = seen.has("snapshot");
105
+ const sawDiff = seen.has("added") || seen.has("removed");
106
+ if (sawSnapshot && sawDiff) return { kind: "malformed" };
107
+ if (sawSnapshot) return { kind: "snapshot", rows: rows.snapshot, empty };
108
+ if (!sawDiff && !empty) return { kind: "malformed" };
109
+ for (const name of rows.added) if (rows.removed.has(name)) return { kind: "malformed" };
110
+ return { kind: "diff", added: rows.added, removed: rows.removed, removedOmitted, empty };
111
+ }
112
+
113
+ function developerTexts(message: OcxMessage): string[] {
114
+ if (message.role !== "developer") return [];
115
+ if (typeof message.content === "string") return [message.content];
116
+ return message.content.flatMap(part => (part.type === "text" ? [part.text] : []));
117
+ }
118
+
119
+ /** Replays every fragment in input order; true when multi_agent_v1 is currently known deferred. */
120
+ function deferredNamespacesIncludeV1(messages: readonly OcxMessage[]): boolean {
121
+ const current = new Set<string>();
122
+ for (const message of messages) {
123
+ for (const text of developerTexts(message)) {
124
+ const fragment = parseFragment(text);
125
+ if (fragment === null) continue;
126
+ switch (fragment.kind) {
127
+ case "malformed":
128
+ current.clear();
129
+ break;
130
+ case "snapshot":
131
+ current.clear();
132
+ for (const name of fragment.rows) current.add(name);
133
+ break;
134
+ case "diff":
135
+ if (fragment.removedOmitted) current.clear();
136
+ for (const name of fragment.added) current.add(name);
137
+ for (const name of fragment.removed) current.delete(name);
138
+ break;
139
+ }
140
+ if (fragment.kind !== "malformed" && fragment.empty) current.clear();
141
+ }
142
+ }
143
+ return current.has(MULTI_AGENT_V1_NAMESPACE);
144
+ }
145
+
146
+ function declaresStructuredToolSearch(tools: readonly OcxTool[] | undefined): boolean {
147
+ return (tools ?? []).some(tool => !tool.namespace && tool.toolSearch === true);
148
+ }
149
+
150
+ /**
151
+ * True when the request's own developer history says V1 collaboration tools are deferred and the
152
+ * request comes from a host that can load deferred tools.
153
+ */
154
+ export function deferredV1Evidence(parsed: OcxParsedRequest, codeMode: CodeModeExecAnalysis): boolean {
155
+ if (!codeMode.genuineExec && !declaresStructuredToolSearch(parsed.context.tools)) return false;
156
+ return deferredNamespacesIncludeV1(parsed.context.messages);
157
+ }
158
+
@@ -0,0 +1,25 @@
1
+ import type { PhysicalSendReporter } from "../../lib/request-execution-budget";
2
+
3
+ /** Retry thunks can fail in pacing; settle only starts admitted at the physical executor. */
4
+ export function deferPhysicalSendReporter(physical: PhysicalSendReporter) {
5
+ let dispatched = 0;
6
+ let reported = 0;
7
+ const report: PhysicalSendReporter = () => {
8
+ const count = dispatched - reported;
9
+ if (count <= 0) return;
10
+ physical(count);
11
+ reported += count;
12
+ };
13
+ // The retry helper owns attempts, while the physical callback owns spend admission.
14
+ report.beforeSend = () => true;
15
+ report.execute = physical.execute;
16
+ report.close = () => { report(0); physical.close?.(); };
17
+ return {
18
+ report,
19
+ beforeDispatch(): boolean {
20
+ if (physical.beforeSend && !physical.beforeSend()) return false;
21
+ dispatched++;
22
+ return true;
23
+ },
24
+ };
25
+ }
@@ -293,11 +293,27 @@ export async function* guardEmptyCompletionEventStream(
293
293
  return;
294
294
  }
295
295
  if (event.type === "error") {
296
- if (retries > 0 && event.status !== 499) {
296
+ if (event.replaySafeBeforeOutput === true && retries < maxRetries) {
297
+ // The upstream failed the turn before any text or tool call (#6876: Vertex dropped a
298
+ // malformed function call). Nothing actionable reached the client, so this is the same
299
+ // replay-safe shape as an empty completion: retry the identical turn once.
300
+ usage = mergeUsage(usage, event.usage);
301
+ retries += 1;
302
+ try {
303
+ source = await options.continuation();
304
+ } catch {
305
+ yield emptyCompletionRetryFailedEvent(usage, true);
306
+ return;
307
+ }
308
+ terminalSeen = true;
309
+ break;
310
+ }
311
+ if (retries > 0 && event.status !== 499 && event.replaySafeBeforeOutput !== true) {
297
312
  // The retry failed upstream. Its body cannot reach the client (the
298
313
  // 200 head went out with the first attempt), so state the failure in
299
314
  // the stream's own error framing — same move as the router's
300
- // empty_completion_retry_failed. Client cancels (499) pass through.
315
+ // empty_completion_retry_failed. Client cancels (499) pass through, and a
316
+ // replay-safe failure that repeats keeps its own, more specific message.
301
317
  yield emptyCompletionRetryFailedEvent(mergeUsage(usage, event.usage), true);
302
318
  return;
303
319
  }
@@ -1,4 +1,5 @@
1
1
  import type { NativeResponseControl } from "./native-response-control";
2
+ import type { CodexWsPreOutputRefusalHold } from "./codex-ws-wire";
2
3
  import type { Server } from "bun";
3
4
  import {
4
5
  codexWsUpstreamFetch,
@@ -28,6 +29,7 @@ import {
28
29
  type ProviderEgressBinding,
29
30
  } from "../../lib/provider-egress";
30
31
  import { redactSecretString } from "../../lib/redact";
32
+ import { outboundCredentials } from "../../lib/outbound-credential-registry";
31
33
 
32
34
  export { withUpstreamHttpVersion };
33
35
 
@@ -205,6 +207,9 @@ export function sendWithConnectionPolicy(
205
207
  && /^https:\/\/chatgpt\.com\/backend-api\/codex\/responses(?:\/compact)?$/.test(target)
206
208
  && Buffer.byteLength(body, "utf8") >= 1024 * 1024;
207
209
  (init as Record<symbol, (() => void) | undefined> | undefined)?.[PHYSICAL_DISPATCH]?.();
210
+ // Final headers, after plugin rewrites: an upstream can only echo what it received here, so the
211
+ // request log refuses any diagnostic that repeats one of these values (see request-log-terminal-status).
212
+ outboundCredentials().remember(headers);
208
213
  return physicalFetch(input, {
209
214
  ...init,
210
215
  ...(largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}),
@@ -240,6 +245,12 @@ export interface ProviderFetchOptions {
240
245
  beforeDispatch?: (headers: Headers) => void;
241
246
  /** One-shot receipt after final local HTTP admission, immediately before executor entry. */
242
247
  onPhysicalDispatch?: () => void;
248
+ /**
249
+ * Opt-in (#6969): let a Codex WebSocket send hold empty lifecycle frames so an authoritative
250
+ * upstream refusal before any output settles as a replayable gateway status. Only a caller that
251
+ * restores the projection afterwards (restoreCodexWsPreOutputRefusal) may set it.
252
+ */
253
+ preOutputRefusalHold?: CodexWsPreOutputRefusalHold;
243
254
  /** Revalidate/rebuild a queued request at its physical send boundary, after pacing. */
244
255
  dispatchOverride?: (input: Parameters<typeof globalThis.fetch>[0], init: RequestInit, execute: typeof globalThis.fetch) => Promise<Response>;
245
256
  }
@@ -356,7 +367,7 @@ export function providerFetch(
356
367
  const fallback = ((url: Parameters<typeof globalThis.fetch>[0], fallbackInit?: RequestInit) =>
357
368
  httpFetch(url, { ...fallbackInit, [WS_FALLBACK_RECEIPT]: notifyOnce } as RequestInit)) as typeof globalThis.fetch;
358
369
  return codexWsUpstreamFetch(input, init, fallback, runtime, options.onCodexWsQuota, options.beforeDispatch, options.nativeControl,
359
- async () => { (await waitForPacing(init.signal ?? undefined))?.release(); }, notifyOnce);
370
+ async () => { (await waitForPacing(init.signal ?? undefined))?.release(); }, notifyOnce, options.preOutputRefusalHold);
360
371
  }
361
372
  return httpFetch(input, init);
362
373
  };
@@ -136,7 +136,7 @@ export function createJevModelInvoker(context: JevModelInvokerContext): JevModel
136
136
  if (new TextEncoder().encode(body).byteLength > JEV_MAX_REQUEST_BYTES) {
137
137
  throw new JevModelInvokeError("malformed", "decision request too large");
138
138
  }
139
- const lease = tryAdmitTurn();
139
+ const lease = tryAdmitTurn(undefined, signal);
140
140
  if (!lease) throw new JevModelInvokeError("network", "decision turn not admitted");
141
141
  const childLog: RequestLogContext = { model, provider: "unknown", inboundProtocol: "responses" };
142
142
  let usage: Record<string, number> | undefined;
@@ -408,6 +408,7 @@ export async function deliverPassthroughResponse(
408
408
  | "noteInspectedPayload"
409
409
  | "normalizeFunctionCompletionJson"
410
410
  | "localUpstream"
411
+ | "terminalOutcomeRecorded"
411
412
  >,
412
413
  ): Promise<Response> {
413
414
  const { route } = requestState;
@@ -486,7 +487,9 @@ export async function deliverPassthroughResponse(
486
487
  ? (status: ResponsesTerminalStatus, httpStatusOverride?: number): void => {
487
488
  if (terminalOutcomeRecorded) return;
488
489
  terminalOutcomeRecorded = true;
489
- recordTerminalOutcome(status, httpStatusOverride);
490
+ // A restored pre-output refusal whose account outcome dispatch already wrote (#6969) keeps
491
+ // every other terminal callback but must not write the same refusal a second time.
492
+ if (!nativeExchange.terminalOutcomeRecorded) recordTerminalOutcome(status, httpStatusOverride);
490
493
  }
491
494
  : undefined;
492
495
  const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;