@bitkyc08/opencodex 2.83.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 (216) 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-DiJO6sYY.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/ollama-native.ts +29 -1
  10. package/src/adapters/openai-chat/messages.ts +8 -4
  11. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  12. package/src/adapters/openai-responses/passthrough.ts +3 -0
  13. package/src/adapters/openai-responses/request-strips.ts +10 -2
  14. package/src/adapters/qoder/adapter.ts +16 -14
  15. package/src/bridge/errors.ts +19 -0
  16. package/src/bridge.ts +1 -1
  17. package/src/claude/context-windows.ts +54 -1
  18. package/src/claude/intercept/connect-proxy.ts +10 -3
  19. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  20. package/src/claude/intercept/picker-listener.ts +49 -1
  21. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  22. package/src/claude/intercept/settings.ts +101 -9
  23. package/src/cli/account-api.ts +65 -0
  24. package/src/cli/account-auth.ts +26 -2
  25. package/src/cli/account.ts +41 -5
  26. package/src/cli/capabilities-access-remote.ts +8 -0
  27. package/src/cli/capabilities-base.ts +13 -1
  28. package/src/cli/catalog-command-result.ts +2 -1
  29. package/src/cli/claude.ts +15 -6
  30. package/src/cli/config-command.ts +8 -2
  31. package/src/cli/debug.ts +8 -0
  32. package/src/cli/dispatch.ts +16 -3
  33. package/src/cli/export-command.ts +14 -1
  34. package/src/cli/hub-disable.ts +99 -0
  35. package/src/cli/hub.ts +4 -2
  36. package/src/cli/index.ts +13 -4
  37. package/src/cli/link-child.ts +104 -0
  38. package/src/cli/link-enrollment.ts +96 -0
  39. package/src/cli/link.ts +39 -24
  40. package/src/cli/observe-stream.ts +6 -1
  41. package/src/cli/observe.ts +10 -2
  42. package/src/cli/provider.ts +12 -0
  43. package/src/cli/registry.ts +5 -4
  44. package/src/cli/runtime-api.ts +27 -2
  45. package/src/cli/status.ts +104 -20
  46. package/src/client/connect.ts +39 -4
  47. package/src/client/hub-client.ts +14 -7
  48. package/src/client/hub-state.ts +25 -14
  49. package/src/client/link-home.ts +248 -0
  50. package/src/client/link-management-relay.ts +61 -0
  51. package/src/client/link-relay.ts +6 -1
  52. package/src/client/link-status.ts +14 -0
  53. package/src/client/machine-api.ts +14 -7
  54. package/src/client/machine-listener.ts +29 -4
  55. package/src/clients/config-export.ts +46 -16
  56. package/src/clients/omo-role-models.ts +6 -3
  57. package/src/codex/agent-role-models.ts +26 -8
  58. package/src/codex/auth-api/account-list.ts +17 -2
  59. package/src/codex/auth-api/account-selection.ts +17 -7
  60. package/src/codex/auth-api/routes.ts +5 -1
  61. package/src/codex/autostart-health.ts +3 -1
  62. package/src/codex/catalog/bundled.ts +5 -1
  63. package/src/codex/catalog/effort.ts +2 -2
  64. package/src/codex/catalog/metadata.ts +4 -2
  65. package/src/codex/catalog/native-models.ts +16 -2
  66. package/src/codex/native-profile-startup.ts +4 -1
  67. package/src/codex/prompt-journal.ts +20 -2
  68. package/src/codex/prompt-layers/encoding.ts +49 -0
  69. package/src/codex/prompt-layers/import-source.ts +30 -0
  70. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  71. package/src/codex/prompt-layers/toml-read.ts +52 -4
  72. package/src/codex/prompt-layers.ts +383 -20
  73. package/src/codex/prompt-lock-claim.ts +13 -3
  74. package/src/codex/prompt-lock-evidence.ts +3 -3
  75. package/src/codex/prompt-lock-owner.ts +14 -6
  76. package/src/codex/prompt-text-probe.ts +34 -5
  77. package/src/codex/quota.ts +13 -6
  78. package/src/codex/role-auto-assign.ts +2 -5
  79. package/src/codex/routing/active-account.ts +21 -0
  80. package/src/codex/routing.ts +1 -1
  81. package/src/codex/runtime.ts +196 -8
  82. package/src/codex/shim-overlay.ts +8 -4
  83. package/src/codex/shim.ts +1 -1
  84. package/src/combos/failover.ts +3 -0
  85. package/src/config/api-key-reconcile.ts +34 -0
  86. package/src/config/diagnostics.ts +16 -0
  87. package/src/config/feature-flags.ts +5 -0
  88. package/src/config/live-reconcile.ts +43 -29
  89. package/src/config/live-topology.ts +29 -0
  90. package/src/config/load-degrade.ts +2 -1
  91. package/src/config/paths.ts +3 -0
  92. package/src/config/salvage.ts +3 -1
  93. package/src/config/schema/config-schema.ts +2 -0
  94. package/src/generated/compatibility-version.json +311 -171
  95. package/src/grok/grpc-web.ts +23 -1
  96. package/src/grok/reset-coupon-ledger.ts +75 -5
  97. package/src/grok/reset-coupons.ts +4 -2
  98. package/src/integrations/mutation-plan.ts +13 -7
  99. package/src/integrations/registry.ts +45 -11
  100. package/src/integrations/state.ts +19 -7
  101. package/src/integrations/target.ts +42 -5
  102. package/src/integrations/writer.ts +14 -6
  103. package/src/lib/app-owned-memory-stores.ts +4 -0
  104. package/src/lib/app-owned-memory.ts +11 -0
  105. package/src/lib/json-byte-size.ts +31 -0
  106. package/src/lib/optional-shutdown-hooks.ts +35 -0
  107. package/src/lib/outbound-credential-registry.ts +210 -0
  108. package/src/lib/passive-read.ts +35 -0
  109. package/src/lib/shadow-call.ts +44 -5
  110. package/src/link/codex-settings.ts +155 -0
  111. package/src/link/management-routes.ts +89 -0
  112. package/src/link/remote-probe.ts +38 -0
  113. package/src/link/ssh-argv.ts +26 -5
  114. package/src/link/ssh-runner.ts +58 -27
  115. package/src/link/status-projection.ts +22 -4
  116. package/src/link/supervisor.ts +144 -49
  117. package/src/link/tunnel-state.ts +11 -4
  118. package/src/oauth/anthropic-continuity.ts +4 -3
  119. package/src/oauth/anthropic-identity.ts +13 -2
  120. package/src/oauth/anthropic-model-quota.ts +13 -1
  121. package/src/oauth/anthropic-routing.ts +230 -23
  122. package/src/oauth/anthropic.ts +18 -4
  123. package/src/oauth/google-antigravity.ts +24 -7
  124. package/src/oauth/index.ts +24 -15
  125. package/src/oauth/kiro-account-identity.ts +106 -0
  126. package/src/oauth/kiro.ts +48 -5
  127. package/src/oauth/plan.ts +29 -0
  128. package/src/oauth/store.ts +40 -3
  129. package/src/oauth/types.ts +2 -0
  130. package/src/plugins/loader.ts +11 -0
  131. package/src/providers/anthropic-billing-ownership.ts +74 -0
  132. package/src/providers/anthropic-billing-recovery.ts +31 -0
  133. package/src/providers/anthropic-billing-refusal.ts +55 -0
  134. package/src/providers/anthropic-reset-admission.ts +62 -0
  135. package/src/providers/anthropic-reset-grant-ledger.ts +28 -6
  136. package/src/providers/api-key-resolve.ts +17 -2
  137. package/src/providers/key-failover.ts +52 -5
  138. package/src/providers/model-rename-startup.ts +5 -3
  139. package/src/providers/quota/account-cache.ts +11 -0
  140. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  141. package/src/providers/quota/report-cache.ts +14 -0
  142. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  143. package/src/providers/quota.ts +4 -1
  144. package/src/providers/registry/entries-core.ts +14 -14
  145. package/src/providers/registry/entries-extended.ts +0 -1
  146. package/src/providers/registry/model-seeds.ts +17 -0
  147. package/src/providers/request-pacing.ts +9 -0
  148. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  149. package/src/responses/compaction.ts +14 -0
  150. package/src/responses/parser.ts +7 -1
  151. package/src/server/auth-cors.ts +42 -0
  152. package/src/server/configured-credentials.ts +194 -0
  153. package/src/server/index/link-management.ts +58 -0
  154. package/src/server/index/optional-listeners.ts +5 -1
  155. package/src/server/index/serve-options.ts +13 -0
  156. package/src/server/lifecycle.ts +2 -1
  157. package/src/server/management/agent-settings-routes.ts +28 -10
  158. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  159. package/src/server/management/codex-agent-role-routes.ts +19 -6
  160. package/src/server/management/codex-prompt-routes.ts +136 -5
  161. package/src/server/management/codex-role-auto-assign.ts +1 -1
  162. package/src/server/management/grok-coupon-routes.ts +78 -27
  163. package/src/server/management/link-enrollment.ts +192 -0
  164. package/src/server/management/link-route-state.ts +185 -0
  165. package/src/server/management/link-routes.ts +82 -402
  166. package/src/server/management/model-rows.ts +3 -1
  167. package/src/server/management/provider-overwrite-carry.ts +10 -0
  168. package/src/server/management/provider-routes.ts +1 -0
  169. package/src/server/management/route-registry.ts +5 -3
  170. package/src/server/management/shared.ts +3 -0
  171. package/src/server/management-api.ts +3 -1
  172. package/src/server/messages-native.ts +68 -42
  173. package/src/server/relay-eager.ts +2 -0
  174. package/src/server/request-log-filter.ts +6 -1
  175. package/src/server/request-log-terminal-status.ts +63 -6
  176. package/src/server/responses/adapter-dispatch.ts +232 -79
  177. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  178. package/src/server/responses/codex-ws-exchange.ts +147 -6
  179. package/src/server/responses/codex-ws-wire.ts +203 -0
  180. package/src/server/responses/collaboration.ts +36 -7
  181. package/src/server/responses/compact.ts +126 -48
  182. package/src/server/responses/context-overflow.ts +13 -1
  183. package/src/server/responses/core-codex-account.ts +105 -1
  184. package/src/server/responses/core-combo-failure.ts +14 -0
  185. package/src/server/responses/core-combo.ts +7 -2
  186. package/src/server/responses/core-errors.ts +29 -1
  187. package/src/server/responses/core-options.ts +2 -0
  188. package/src/server/responses/deferred-collab-surface.ts +158 -0
  189. package/src/server/responses/deferred-send-reporter.ts +25 -0
  190. package/src/server/responses/fetch-helpers.ts +12 -1
  191. package/src/server/responses/passthrough-delivery.ts +4 -1
  192. package/src/server/responses/passthrough-dispatch.ts +15 -4
  193. package/src/server/responses/request-prepare.ts +6 -4
  194. package/src/server/responses/request-transport.ts +6 -8
  195. package/src/server/responses/sidecar-execution.ts +11 -2
  196. package/src/server/responses/ws-upstream.ts +9 -1
  197. package/src/server/startup-action-control.ts +22 -2
  198. package/src/server/startup-health-cache.ts +4 -0
  199. package/src/server/system-env-shell.ts +7 -2
  200. package/src/server/system-env.ts +11 -1
  201. package/src/service/cli.ts +5 -1
  202. package/src/service/desktop-startup.ts +147 -34
  203. package/src/service/desktop-supervision.d.mts +27 -1
  204. package/src/service/desktop-supervision.mjs +111 -5
  205. package/src/service/health-sentinel-launch.ts +176 -0
  206. package/src/service/health-sentinel-main.ts +447 -0
  207. package/src/service/health-sentinel-policy.ts +241 -0
  208. package/src/service/health-sentinel-probe.ts +54 -0
  209. package/src/service/health-sentinel-windows.ts +87 -0
  210. package/src/service/linux-linger.ts +20 -0
  211. package/src/types/config.ts +5 -0
  212. package/src/usage/expected-prices.ts +4 -0
  213. package/src/usage/log.ts +19 -1
  214. package/gui/dist/assets/App-Ba8uG696.js +0 -54
  215. package/gui/dist/assets/Tray-IImbNPRy.js +0 -1
  216. package/gui/dist/assets/index-BHRZdJuv.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.";
@@ -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. */
@@ -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
+ }
@@ -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
  };
@@ -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;
@@ -91,7 +91,7 @@ import {
91
91
  safeHostLabel,
92
92
  } from "./fetch-helpers";
93
93
  import { classifyPoolRecoveryDispatch } from "../../routing/probe-lease";
94
- import { clientCancelledResponse } from "./core-errors";
94
+ import { anthropicPoolCooldownResponse, clientCancelledResponse } from "./core-errors";
95
95
  import {
96
96
  upstreamHostCircuitOpenResponse,
97
97
  usesCodexForwardPoolAuth,
@@ -101,6 +101,8 @@ import {
101
101
  shouldRetryCodexPoolAccountQuota,
102
102
  shouldRetryCodexPoolAccountTransient,
103
103
  retryCodexPoolOnAlternateAccount,
104
+ PoolPreOutputRefusals,
105
+ poolPreOutputRefusalHold,
104
106
  } from "./core-codex-account";
105
107
  import {
106
108
  clearCodexModelDenialEvidence,
@@ -949,8 +951,7 @@ export async function preparePassthroughExchange(
949
951
  return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(refusal));
950
952
  }
951
953
  if (refusal instanceof AnthropicAccountCooldownError) {
952
- return formatErrorResponse(429, "rate_limit_error", refusal.message,
953
- refusal.retryAfterSeconds === null ? undefined : { retryAfter: String(refusal.retryAfterSeconds) });
954
+ return anthropicPoolCooldownResponse(refusal, { comboAttempt: options.comboAttempt, logCtx });
954
955
  }
955
956
  return formatErrorResponse(403, "permission_error", publicOAuthAuthenticationErrorMessage(refusal));
956
957
  }
@@ -1005,6 +1006,9 @@ export async function preparePassthroughExchange(
1005
1006
  // Stable per request: the Combo booking (`reserveDispatch`) already started and froze the spend
1006
1007
  // policy, so `spendEnforced` cannot change before dispatch even though no reporter starts it here.
1007
1008
  const receiptMode = !sendBudgetState.adapterSendBudget?.spendEnforced && Boolean(options.comboInitialSend);
1009
+ // #6969: Pool sends hold empty lifecycle frames so a pre-output refusal reaches the ladder below.
1010
+ const preOutputRefusals = new PoolPreOutputRefusals();
1011
+ const preOutputRefusalHold = () => poolPreOutputRefusalHold(admissionState.authCtx, route.provider, connectMs, options.comboAttempt);
1008
1012
  try {
1009
1013
  // Transient-5xx pre-stream retry (devlog/_plan/260716_claudecode_hardening/010):
1010
1014
  // the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
@@ -1035,11 +1039,12 @@ export async function preparePassthroughExchange(
1035
1039
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1036
1040
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1037
1041
  ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
1042
+ preOutputRefusalHold: preOutputRefusalHold(),
1038
1043
  }),
1039
1044
  route.provider.authMode === "forward")
1040
1045
  // Every real attempt response — including an intermediate 5xx the
1041
1046
  // retry wrapper replaces — proves the host was reached (#914 review).
1042
- .then(adoptObservedResponse);
1047
+ .then(adoptObservedResponse).then(preOutputRefusals.observe);
1043
1048
  },
1044
1049
  { abortSignal: upstream.signal, label: safeHostLabel(request.url),
1045
1050
  attempts: sendBudgetState.initialSendAllowance(transientSendAttempts()),
@@ -1050,6 +1055,8 @@ export async function preparePassthroughExchange(
1050
1055
  },
1051
1056
  );
1052
1057
  } catch (err) {
1058
+ preOutputRefusals.recordOnLocalExit(err, options.abortSignal?.aborted === true || req.signal.aborted,
1059
+ config, admissionState.authCtx, route.provider, route.modelId);
1053
1060
  return transportFailureResponse(err);
1054
1061
  } finally {
1055
1062
  request.releaseBodyObservation?.();
@@ -1676,6 +1683,7 @@ export async function preparePassthroughExchange(
1676
1683
  passthroughEstimate,
1677
1684
  stream: upstreamRequestsStream,
1678
1685
  httpOnly: canonicalBufferedJson,
1686
+ preOutputRefusalHold: preOutputRefusalHold(),
1679
1687
  onResponse: (response, retryAuthCtx, retryRequest) => {
1680
1688
  adoptCodexWsStage(response);
1681
1689
  captureAffinityResponse(
@@ -1702,6 +1710,8 @@ export async function preparePassthroughExchange(
1702
1710
  }
1703
1711
  }
1704
1712
  }
1713
+ // #6969: a projection nothing above recovered goes on as the original stream, before any reader.
1714
+ upstreamResponse = preOutputRefusals.restore(upstreamResponse);
1705
1715
  // The deterministic route record cannot classify history it never observed (restart, expiry,
1706
1716
  // eviction, or an older transcript). Inspect only a bounded clone of a 4xx whose exact outbound
1707
1717
  // Responses body still carries opaque state, then rebuild once through the ordinary adapter
@@ -1969,6 +1979,7 @@ export async function preparePassthroughExchange(
1969
1979
  connectMs,
1970
1980
  localUpstream,
1971
1981
  upstreamResponse,
1982
+ terminalOutcomeRecorded: preOutputRefusals.outcomeRecorded,
1972
1983
  };
1973
1984
  }
1974
1985
 
@@ -33,6 +33,7 @@ import { resolveContextPrincipal } from "../auth-cors";
33
33
  import { resolveSkillsSnapshotScopeKey, snapshotSkillsCatalogInBody } from "./skills-snapshot";
34
34
  import {
35
35
  isShadowSourceModel,
36
+ shadowCallPreservesSelectedModel,
36
37
  shadowSourceModelPrefix,
37
38
  shouldInterceptShadowCall,
38
39
  } from "../../lib/shadow-call";
@@ -275,10 +276,11 @@ export async function prepareResponsesRequest(
275
276
  }
276
277
  }
277
278
  let shadowCallIntercepted = false;
278
- // A spawned sub-agent turn names its model on purpose; gpt-6-luna is both the helper
279
- // slug and a default sub-agent model, so neither intercept site may rewrite that turn.
279
+ // Luna is also selectable by users/children. Both intercept sites use the same
280
+ // per-turn provenance; WS frames must not inherit the handshake's classification.
280
281
  const threadSpawn = isThreadSpawnRequest(req.headers);
281
- if (!concreteSelection && !options.compactionRoutingOverride && !options.internalDecisionCall && !threadSpawn && !memoryModelApplies && body && typeof body === "object" && !Array.isArray(body)) {
282
+ const preserveShadowModel = shadowCallPreservesSelectedModel(body, req.headers, options.inboundTransport);
283
+ if (!concreteSelection && !options.compactionRoutingOverride && !options.internalDecisionCall && !preserveShadowModel && !memoryModelApplies && body && typeof body === "object" && !Array.isArray(body)) {
282
284
  const shadowIntercept = config.shadowCallIntercept;
283
285
  const rawShadowModel = (body as { model?: unknown }).model;
284
286
  if (shadowIntercept?.enabled && shadowIntercept.model && typeof rawShadowModel === "string"
@@ -613,7 +615,7 @@ export async function prepareResponsesRequest(
613
615
  }
614
616
  const _sci = config.shadowCallIntercept;
615
617
  let shadowRoute: RouteResult | undefined;
616
- if (!concreteSelection && !memoryRoute && !options.memoryModelPhase && !options.compactionRoutingOverride && !options.internalDecisionCall && !threadSpawn && _sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
618
+ if (!concreteSelection && !memoryRoute && !options.memoryModelPhase && !options.compactionRoutingOverride && !options.internalDecisionCall && !preserveShadowModel && _sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
617
619
  const sourcePrefix = shadowSourceModelPrefix(parsed.modelId, _sci.sourceModels)!;
618
620
  let sourceIdentity = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourcePrefix };
619
621
  try {