@bitkyc08/opencodex 2.88.0 → 2.90.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 (291) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
  7. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +2 -2
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/base.ts +4 -0
  14. package/src/adapters/claude-cli/adapter.ts +13 -7
  15. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/coding-agent/turn.ts +14 -4
  18. package/src/adapters/cursor/protobuf-request.ts +3 -0
  19. package/src/adapters/google-http.ts +3 -2
  20. package/src/adapters/google-progress-history.ts +19 -1
  21. package/src/adapters/google-vertex-endpoint.ts +67 -0
  22. package/src/adapters/google.ts +62 -37
  23. package/src/adapters/image.ts +5 -3
  24. package/src/adapters/input-media-guard.ts +5 -2
  25. package/src/adapters/kiro-refusal.ts +41 -1
  26. package/src/adapters/ollama-native.ts +1 -0
  27. package/src/adapters/openai-chat/messages.ts +34 -4
  28. package/src/adapters/openai-chat/passthrough.ts +37 -4
  29. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  30. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  31. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  32. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  33. package/src/adapters/openai-chat.ts +7 -6
  34. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  35. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  36. package/src/adapters/openai-responses/passthrough.ts +22 -13
  37. package/src/adapters/openai-responses/reasoning.ts +68 -1
  38. package/src/adapters/openai-responses/transport.ts +13 -1
  39. package/src/adapters/registry.ts +1 -1
  40. package/src/adapters/upstream-http-error.ts +3 -2
  41. package/src/adapters/zed.ts +7 -4
  42. package/src/bridge/internal.ts +11 -1
  43. package/src/bridge/response-json.ts +2 -2
  44. package/src/bridge/sse.ts +10 -4
  45. package/src/claude/desktop-3p-library.ts +43 -3
  46. package/src/claude/desktop-3p.ts +20 -10
  47. package/src/claude/desktop-picker-profile.ts +18 -9
  48. package/src/claude/desktop-remote-store-io.ts +2 -2
  49. package/src/claude/inbound.ts +12 -26
  50. package/src/claude/outbound.ts +5 -0
  51. package/src/cli/account-api.ts +13 -1
  52. package/src/cli/account-extended.ts +17 -5
  53. package/src/cli/account-key-quota.ts +4 -1
  54. package/src/cli/account.ts +1 -1
  55. package/src/cli/capabilities-agents-routing.ts +45 -8
  56. package/src/cli/capabilities-base.ts +5 -5
  57. package/src/cli/capabilities-provider-models.ts +5 -4
  58. package/src/cli/combo-result.ts +8 -2
  59. package/src/cli/combo.ts +17 -0
  60. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  61. package/src/cli/help.ts +1 -1
  62. package/src/cli/message-command.ts +4 -1
  63. package/src/cli/message-remote-args.ts +69 -0
  64. package/src/cli/message-remote-command.ts +77 -0
  65. package/src/cli/provider-runtime.ts +69 -1
  66. package/src/cli/registry.ts +3 -3
  67. package/src/codex/account-lifecycle.ts +19 -6
  68. package/src/codex/account-pause.ts +6 -1
  69. package/src/codex/account-runtime-state.ts +5 -0
  70. package/src/codex/account-store.ts +55 -9
  71. package/src/codex/account-usability.ts +4 -1
  72. package/src/codex/auth-api/account-list.ts +13 -5
  73. package/src/codex/auth-api/account-pause-group.ts +2 -22
  74. package/src/codex/auth-api/login-collision.ts +59 -0
  75. package/src/codex/auth-api/login-flow.ts +12 -7
  76. package/src/codex/auth-api/main-account-probe.ts +3 -2
  77. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  78. package/src/codex/auth-collision.ts +31 -4
  79. package/src/codex/auth-context.ts +57 -10
  80. package/src/codex/catalog/aggregation.ts +15 -0
  81. package/src/codex/catalog/auto-review.ts +2 -1
  82. package/src/codex/catalog/build-entries.ts +17 -7
  83. package/src/codex/catalog/derive-entry.ts +3 -1
  84. package/src/codex/catalog/metadata.ts +2 -0
  85. package/src/codex/catalog/model-visibility.ts +0 -2
  86. package/src/codex/catalog/ownership.ts +71 -0
  87. package/src/codex/catalog/parsing.ts +12 -3
  88. package/src/codex/catalog/retained-sync.ts +2 -1
  89. package/src/codex/catalog/routed-removal.ts +4 -4
  90. package/src/codex/cli-update-plan.ts +18 -3
  91. package/src/codex/collision-identity.ts +78 -0
  92. package/src/codex/convergence.ts +2 -1
  93. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  94. package/src/codex/low-quota-protection.ts +57 -31
  95. package/src/codex/main-account-cache.ts +77 -3
  96. package/src/codex/main-account-hard-lock.ts +14 -0
  97. package/src/codex/main-account.ts +6 -2
  98. package/src/codex/native-residue.ts +2 -3
  99. package/src/codex/quota.ts +26 -2
  100. package/src/codex/routing/cooldown-math.ts +5 -0
  101. package/src/codex/routing/failure-window.ts +61 -9
  102. package/src/codex/routing.ts +4 -3
  103. package/src/codex/shim-diagnostics.ts +2 -1
  104. package/src/codex/shim-fingerprint.ts +11 -2
  105. package/src/codex/shim-templates.ts +16 -5
  106. package/src/codex/shim-windows-refresh.ts +224 -0
  107. package/src/codex/shim.ts +45 -21
  108. package/src/combos/failover.ts +10 -2
  109. package/src/combos/jev-model-backend.ts +30 -7
  110. package/src/combos/jev-model-request.ts +20 -0
  111. package/src/combos/jev-quota-activation.ts +153 -0
  112. package/src/combos/jev-quota-config.ts +62 -0
  113. package/src/combos/jev-quota-route.ts +26 -0
  114. package/src/combos/jev-quota.ts +88 -0
  115. package/src/combos/jev.ts +25 -12
  116. package/src/combos/types.ts +27 -0
  117. package/src/compatibility/index.ts +3 -0
  118. package/src/compatibility/openai-responses.ts +8 -1
  119. package/src/compatibility/opengateway-responses.ts +61 -0
  120. package/src/config/account-auto-pause-revisions.ts +136 -0
  121. package/src/config/diagnostics.ts +13 -0
  122. package/src/config/live-reconcile.ts +9 -0
  123. package/src/config/persist-unlocked.ts +10 -1
  124. package/src/config/provider-validation.ts +7 -2
  125. package/src/config/removed-opencode-free.ts +135 -0
  126. package/src/config/schema/account-auto-pause.ts +14 -0
  127. package/src/config/schema/config-schema.ts +15 -0
  128. package/src/config/schema/leaf-validators.ts +7 -0
  129. package/src/config.ts +10 -3
  130. package/src/generated/compatibility-version.json +476 -216
  131. package/src/generated/model-metadata.ts +1 -1
  132. package/src/images/loop.ts +34 -11
  133. package/src/lib/account-auto-pause-response.ts +16 -0
  134. package/src/lib/bun-runtime.ts +18 -1
  135. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  136. package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
  137. package/src/lib/decision-quota-sink.ts +76 -0
  138. package/src/lib/provider-admission-wait.ts +14 -0
  139. package/src/lib/service-secrets.ts +37 -2
  140. package/src/lib/state-store-registrations.ts +25 -0
  141. package/src/lib/translator-budget.ts +1 -1
  142. package/src/lib/upstream-retry.ts +15 -0
  143. package/src/link/ssh-argv.ts +3 -0
  144. package/src/messaging/envelope.ts +6 -3
  145. package/src/messaging/remote-auth.ts +83 -0
  146. package/src/messaging/remote-bridge.ts +270 -0
  147. package/src/messaging/remote-contract.ts +108 -0
  148. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  149. package/src/messaging/remote-enrollment.ts +244 -0
  150. package/src/messaging/remote-envelope.ts +40 -0
  151. package/src/messaging/remote-files.ts +117 -0
  152. package/src/messaging/remote-owner.ts +169 -0
  153. package/src/messaging/remote-ports.ts +56 -0
  154. package/src/messaging/remote-process.ts +131 -0
  155. package/src/messaging/remote-replies.ts +98 -0
  156. package/src/messaging/remote-rpc-admission.ts +32 -0
  157. package/src/messaging/remote-send.ts +72 -0
  158. package/src/messaging/remote-store.ts +111 -0
  159. package/src/messaging/remote-tunnels.ts +65 -0
  160. package/src/messaging/rpc.ts +13 -1
  161. package/src/oauth/account-auto-pause-policy.ts +66 -0
  162. package/src/oauth/account-auto-pause.ts +171 -0
  163. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  164. package/src/oauth/account-import/parser.ts +15 -2
  165. package/src/oauth/account-import/types.ts +7 -0
  166. package/src/oauth/account-quota-rank.ts +2 -1
  167. package/src/oauth/anthropic-account-refusal.ts +6 -0
  168. package/src/oauth/anthropic-model-family.ts +8 -0
  169. package/src/oauth/anthropic-model-quota.ts +2 -6
  170. package/src/oauth/api-key-pool-preserve.ts +37 -0
  171. package/src/oauth/chatgpt-shared.ts +10 -1
  172. package/src/oauth/dsh.ts +45 -2
  173. package/src/oauth/index.ts +5 -35
  174. package/src/oauth/kiro.ts +44 -1
  175. package/src/oauth/login-flow-state.ts +11 -2
  176. package/src/oauth/store.ts +35 -8
  177. package/src/oauth/types.ts +2 -0
  178. package/src/plugins/upstream-hooks.ts +14 -1
  179. package/src/providers/api-key-pause.ts +32 -0
  180. package/src/providers/api-key-resolve.ts +10 -4
  181. package/src/providers/api-key-selection.ts +6 -1
  182. package/src/providers/api-keys.ts +8 -4
  183. package/src/providers/claude-cli-usage.ts +25 -5
  184. package/src/providers/initial-model-selection.ts +2 -2
  185. package/src/providers/key-failover.ts +21 -13
  186. package/src/providers/key-store.ts +8 -3
  187. package/src/providers/mistral-transport.ts +207 -0
  188. package/src/providers/model-rename-fields.ts +1 -0
  189. package/src/providers/openai-sidecar.ts +5 -0
  190. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  191. package/src/providers/quota/account-cache.ts +15 -1
  192. package/src/providers/quota/anthropic-account-quota.ts +20 -7
  193. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  194. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  195. package/src/providers/quota/antigravity.ts +45 -8
  196. package/src/providers/quota/failure-codes.ts +18 -0
  197. package/src/providers/quota/report-cache.ts +14 -0
  198. package/src/providers/quota/vendor-probes-oauth.ts +15 -6
  199. package/src/providers/quota-decision-publication.ts +77 -0
  200. package/src/providers/quota-decision-snapshot.ts +145 -0
  201. package/src/providers/quota-routing-cache.ts +4 -1
  202. package/src/providers/quota-types.ts +6 -1
  203. package/src/providers/quota.ts +8 -3
  204. package/src/providers/registry/entries-extended.ts +14 -53
  205. package/src/providers/registry/model-seeds.ts +1 -1
  206. package/src/providers/registry.ts +1 -2
  207. package/src/providers/routed-config.ts +1 -1
  208. package/src/providers/stale-vision-classification-migration.ts +1 -1
  209. package/src/providers/xai-transport.ts +2 -1
  210. package/src/responses/audio.ts +46 -0
  211. package/src/responses/input-media.ts +37 -15
  212. package/src/responses/parser-content.ts +22 -27
  213. package/src/responses/parser.ts +18 -10
  214. package/src/responses/reasoning-retention.ts +218 -11
  215. package/src/responses/schema.ts +3 -4
  216. package/src/responses/web-search-replay.ts +147 -0
  217. package/src/routing/compatibility/behavior.ts +4 -1
  218. package/src/server/auth-cors.ts +7 -0
  219. package/src/server/background-lifecycle.ts +2 -1
  220. package/src/server/chat-native.ts +36 -20
  221. package/src/server/claude-messages.ts +2 -1
  222. package/src/server/index/optional-listeners.ts +6 -0
  223. package/src/server/index/serve-options.ts +8 -2
  224. package/src/server/index/websocket-handler.ts +10 -0
  225. package/src/server/lifecycle.ts +17 -1
  226. package/src/server/management/combo-routes.ts +17 -2
  227. package/src/server/management/oauth-account-routes.ts +12 -2
  228. package/src/server/management/provider-completion-test.ts +289 -0
  229. package/src/server/management/provider-routes.ts +9 -1
  230. package/src/server/management/route-registry.ts +2 -0
  231. package/src/server/messages-native.ts +29 -4
  232. package/src/server/messages-shared.ts +4 -1
  233. package/src/server/request-log.ts +23 -9
  234. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  235. package/src/server/responses/adapter-continuation.ts +2 -2
  236. package/src/server/responses/adapter-dispatch.ts +79 -30
  237. package/src/server/responses/codex-ws-exchange.ts +3 -0
  238. package/src/server/responses/codex-ws-metadata.ts +44 -0
  239. package/src/server/responses/codex-ws-session.ts +21 -1
  240. package/src/server/responses/combo-session-recall.ts +25 -0
  241. package/src/server/responses/combo-stream-preflight.ts +10 -3
  242. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  243. package/src/server/responses/compact.ts +10 -5
  244. package/src/server/responses/context-overflow.ts +141 -2
  245. package/src/server/responses/core-auth.ts +20 -0
  246. package/src/server/responses/core-codex-account.ts +28 -15
  247. package/src/server/responses/core-combo.ts +34 -5
  248. package/src/server/responses/core-opaque-recovery.ts +20 -11
  249. package/src/server/responses/core.ts +12 -9
  250. package/src/server/responses/deepseek-image-files.ts +121 -0
  251. package/src/server/responses/empty-completion-guard.ts +10 -0
  252. package/src/server/responses/fetch-helpers.ts +65 -14
  253. package/src/server/responses/input-admission.ts +6 -2
  254. package/src/server/responses/jev-model-invoke.ts +13 -14
  255. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  256. package/src/server/responses/native-steering-log.ts +2 -2
  257. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  258. package/src/server/responses/passthrough-completion.ts +41 -0
  259. package/src/server/responses/passthrough-delivery.ts +5 -2
  260. package/src/server/responses/passthrough-dispatch.ts +42 -45
  261. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  262. package/src/server/responses/passthrough-observations.ts +38 -0
  263. package/src/server/responses/reasoning-archive-error.ts +21 -0
  264. package/src/server/responses/request-compression.ts +98 -0
  265. package/src/server/responses/request-prepare.ts +11 -1
  266. package/src/server/responses/request-transport.ts +92 -5
  267. package/src/server/responses/run-turn-execution.ts +2 -0
  268. package/src/server/responses/sidecar-execution.ts +16 -3
  269. package/src/server/responses/terminal-guard.ts +2 -0
  270. package/src/server/startup-action-control.ts +2 -2
  271. package/src/server/startup-health-cache.ts +2 -2
  272. package/src/server/windows-tray-control.ts +2 -2
  273. package/src/service/definition.ts +9 -1
  274. package/src/service/orchestration.ts +17 -0
  275. package/src/types/account-auto-pause.ts +15 -0
  276. package/src/types/config.ts +23 -1
  277. package/src/types/provider.ts +11 -5
  278. package/src/types/request.ts +36 -3
  279. package/src/types.ts +2 -0
  280. package/src/usage/cost.ts +17 -1
  281. package/src/usage/expected-prices.ts +3 -1
  282. package/src/usage/jev-stats.ts +4 -0
  283. package/src/usage/log.ts +2 -0
  284. package/src/usage/summary.ts +7 -0
  285. package/src/web-search/loop.ts +39 -13
  286. package/src/web-search/progress-stream.ts +4 -1
  287. package/src/web-search/run-turn-loop.ts +9 -3
  288. package/src/web-search/sidecar-search-replay.ts +96 -0
  289. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  290. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  291. package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
@@ -0,0 +1,232 @@
1
+ import { guardResponseBodyInactivity } from "../../lib/response-body-inactivity";
2
+ import { isNonReplayableResponse } from "../../lib/upstream-retry";
3
+ import { isPlainObject } from "../responses-snapshot-codec";
4
+ import { BoundedSseFrameBuffer, joinSseFrameBytes } from "../sse-frame-buffer";
5
+ import { sseDataPayload } from "../sse-payload-rewrite";
6
+ import { EMPTY_COMPLETION_MAX_BUFFERED_BYTES, EMPTY_COMPLETION_MAX_BUFFERED_EVENTS, EMPTY_COMPLETION_RETRY_ENV } from "./empty-completion-guard";
7
+ import { selfContainedResponsesBody } from "./reset-replay";
8
+ import { carryResponseToolOwnership } from "./response-tool-ownership";
9
+
10
+ const MAX_BYTES = EMPTY_COMPLETION_MAX_BUFFERED_BYTES;
11
+ const MAX_EVENTS = EMPTY_COMPLETION_MAX_BUFFERED_EVENTS;
12
+ const MAX_HOLD_MS = 30_000;
13
+ const decoder = new TextDecoder();
14
+ const reasoningEvents = new Set([
15
+ "response.reasoning_summary_part.added", "response.reasoning_summary_part.done",
16
+ "response.reasoning_summary_text.delta", "response.reasoning_summary_text.done",
17
+ "response.reasoning_text.delta", "response.reasoning_text.done",
18
+ ]);
19
+ type RecordValue = Record<string, unknown>;
20
+
21
+ function partCommits(part: unknown): boolean {
22
+ return !isPlainObject(part) || part.type !== "output_text" || part.text !== ""
23
+ || (part.annotations !== undefined && (!Array.isArray(part.annotations) || part.annotations.length > 0));
24
+ }
25
+ function outputCommits(output: unknown): boolean {
26
+ if (output === undefined) return false;
27
+ return !Array.isArray(output) || output.some(item => !isPlainObject(item)
28
+ || (item.type !== "reasoning" && (item.type !== "message" || !Array.isArray(item.content) || item.content.some(partCommits))));
29
+ }
30
+ function wellFormedEmpty(snapshot: unknown): snapshot is RecordValue {
31
+ return isPlainObject(snapshot) && typeof snapshot.id === "string" && snapshot.id.trim().length > 0
32
+ && snapshot.object === "response" && snapshot.status === "completed" && Array.isArray(snapshot.output)
33
+ && snapshot.error == null && snapshot.incomplete_details == null
34
+ && snapshot.output.every(item => isPlainObject(item) && typeof item.id === "string" && item.id.length > 0
35
+ && (item.type === "reasoning" || (item.type === "message" && item.role === "assistant"
36
+ && item.status === "completed" && Array.isArray(item.content) && !item.content.some(partCommits))));
37
+ }
38
+ function eventCommits(event: RecordValue): boolean {
39
+ if (event.replayUnsafe === true) return true;
40
+ switch (event.type) {
41
+ case "response.created": case "response.in_progress": case "response.queued": case "response.heartbeat":
42
+ return isPlainObject(event.response) && outputCommits(event.response.output);
43
+ case "response.output_item.added": case "response.output_item.done": return outputCommits([event.item]);
44
+ case "response.content_part.added": case "response.content_part.done": return partCommits(event.part);
45
+ case "response.output_text.delta": return event.delta !== "";
46
+ case "response.output_text.done": return event.text !== "";
47
+ default: return !reasoningEvents.has(String(event.type));
48
+ }
49
+ }
50
+
51
+ export interface OpenGatewayCompletionGuardOptions {
52
+ readonly url: string;
53
+ readonly body: unknown;
54
+ readonly signal: AbortSignal;
55
+ readonly retry: () => Promise<Response>;
56
+ readonly recordUsage: (usage: unknown) => void;
57
+ readonly retryEnabled?: boolean;
58
+ readonly stallTimeoutMs?: number;
59
+ /** Expiry releases original bytes; it never aborts a healthy upstream. */
60
+ readonly holdTimeoutMs?: number;
61
+ }
62
+
63
+ type Inspection = { kind: "pass"; response: Response }
64
+ | { kind: "empty" | "invalid"; snapshot: RecordValue; streaming: boolean };
65
+
66
+ async function inspect(response: Response, options: OpenGatewayCompletionGuardOptions): Promise<Inspection> {
67
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
68
+ const streaming = contentType.includes("text/event-stream")
69
+ || (!contentType && isPlainObject(options.body) && options.body.stream === true);
70
+ if (!response.body || (!streaming && !contentType.includes("application/json"))) return { kind: "pass", response };
71
+ const guarded = guardResponseBodyInactivity(response, options.signal, streaming ? options.stallTimeoutMs ?? 0 : 0);
72
+ const reader = guarded.response.body?.getReader();
73
+ if (!reader) return { kind: "pass", response };
74
+ const framer = new BoundedSseFrameBuffer(MAX_BYTES);
75
+ const held: Array<Uint8Array | undefined> = [];
76
+ let bytes = 0, events = 0, transferred = false, cleaned = false;
77
+ let pending: ReturnType<typeof reader.read> | undefined;
78
+ const holdMs = options.holdTimeoutMs ?? MAX_HOLD_MS;
79
+ const deadline = performance.now() + holdMs;
80
+ let timer: ReturnType<typeof setTimeout> | undefined;
81
+ const expired = new Promise<"expired">(resolve => { timer = setTimeout(() => resolve("expired"), holdMs); });
82
+ const dropHeld = (): void => {
83
+ for (let index = 0; index < held.length; index += 1) held[index] = undefined;
84
+ };
85
+ const finishOwned = (): void => {
86
+ if (cleaned) return;
87
+ cleaned = true;
88
+ guarded.dispose();
89
+ try { reader.releaseLock(); }
90
+ catch (error) { if (!(error instanceof TypeError)) throw error; }
91
+ };
92
+ const cancelOwned = (reason?: unknown): void => {
93
+ if (cleaned) return;
94
+ cleaned = true;
95
+ dropHeld();
96
+ try { void reader.cancel(reason).catch(() => {}); }
97
+ catch (error) { if (!(error instanceof TypeError)) throw error; }
98
+ guarded.dispose();
99
+ try { reader.releaseLock(); }
100
+ catch (error) { if (!(error instanceof TypeError)) throw error; }
101
+ };
102
+ const pass = (): Inspection => {
103
+ transferred = true;
104
+ let index = 0;
105
+ const body = new ReadableStream<Uint8Array>({
106
+ async pull(controller) {
107
+ if (index < held.length) {
108
+ const chunk = held[index];
109
+ held[index] = undefined;
110
+ index += 1;
111
+ if (chunk) controller.enqueue(chunk);
112
+ return;
113
+ }
114
+ try {
115
+ const next = await (pending ?? reader.read());
116
+ pending = undefined;
117
+ if (next.done) { finishOwned(); controller.close(); }
118
+ else controller.enqueue(next.value);
119
+ } catch (error) { cancelOwned(error); controller.error(error); }
120
+ },
121
+ cancel(reason) { dropHeld(); cancelOwned(reason); },
122
+ }, { highWaterMark: 0 });
123
+ return { kind: "pass", response: carryResponseToolOwnership(response, new Response(body, {
124
+ status: response.status, statusText: response.statusText, headers: response.headers,
125
+ })) };
126
+ };
127
+ const terminal = (snapshot: unknown): Inspection => ({
128
+ kind: wellFormedEmpty(snapshot) ? "empty" : "invalid",
129
+ snapshot: isPlainObject(snapshot) ? snapshot : {}, streaming,
130
+ });
131
+ try {
132
+ for (;;) {
133
+ if (performance.now() >= deadline) return pass();
134
+ pending = reader.read();
135
+ let next: Awaited<typeof pending> | "expired";
136
+ try { next = await Promise.race([pending, expired]); }
137
+ catch { return pass(); }
138
+ if (next === "expired") return pass();
139
+ pending = undefined;
140
+ if (!next.done) {
141
+ if (bytes + next.value.byteLength > MAX_BYTES || held.length >= MAX_EVENTS) {
142
+ held.push(next.value);
143
+ return pass();
144
+ }
145
+ held.push(next.value.slice());
146
+ bytes += next.value.byteLength;
147
+ }
148
+ if (!streaming) {
149
+ if (!next.done) continue;
150
+ let snapshot: unknown;
151
+ try { snapshot = JSON.parse(decoder.decode(joinSseFrameBytes(held.flatMap(chunk => chunk ? [chunk] : [])))); }
152
+ catch { return terminal(undefined); }
153
+ if (isPlainObject(snapshot) && (outputCommits(snapshot.output)
154
+ || snapshot.error != null || ["failed", "incomplete", "queued", "in_progress"].includes(String(snapshot.status)))) return pass();
155
+ return terminal(snapshot);
156
+ }
157
+ let frames;
158
+ try { frames = next.done ? [{ block: framer.finish() }] : framer.feed(next.value); }
159
+ catch { return pass(); }
160
+ for (const raw of frames) {
161
+ if (raw.block.byteLength === 0) continue;
162
+ if (++events >= MAX_EVENTS) return pass();
163
+ const block = decoder.decode(raw.block), data = sseDataPayload(block);
164
+ if (data === null) { if (/^event:/m.test(block)) return pass(); continue; }
165
+ let event: unknown;
166
+ try { event = JSON.parse(data); } catch { return pass(); }
167
+ if (!isPlainObject(event)) return pass();
168
+ if (event.replayUnsafe === true) return pass();
169
+ if (event.type === "response.completed") {
170
+ if (isPlainObject(event.response) && outputCommits(event.response.output)) return pass();
171
+ return terminal(event.response);
172
+ }
173
+ if (eventCommits(event)) return pass();
174
+ }
175
+ if (next.done) return pass();
176
+ }
177
+ } finally {
178
+ clearTimeout(timer); framer.dispose();
179
+ if (!transferred) cancelOwned();
180
+ }
181
+ }
182
+
183
+ function terminalResponse(source: Response, inspection: Exclude<Inspection, { kind: "pass" }>, reason: string): Response {
184
+ const incomplete = reason === "max_output_tokens";
185
+ const snapshot: RecordValue = { ...inspection.snapshot, status: incomplete ? "incomplete" : "failed" };
186
+ if (incomplete) {
187
+ snapshot.incomplete_details = { reason };
188
+ delete snapshot.error;
189
+ } else {
190
+ snapshot.error = {
191
+ type: "upstream_error", code: reason, message: "OpenGateway returned no usable completion before output.",
192
+ };
193
+ delete snapshot.incomplete_details;
194
+ }
195
+ const headers = new Headers(source.headers);
196
+ headers.delete("content-length"); headers.delete("content-encoding");
197
+ if (inspection.streaming) headers.set("content-type", "text/event-stream");
198
+ const text = inspection.streaming
199
+ ? `event: response.${snapshot.status}\ndata: ${JSON.stringify({ type: `response.${snapshot.status}`, response: snapshot })}\n\n`
200
+ : JSON.stringify(snapshot);
201
+ return carryResponseToolOwnership(source, new Response(text, {
202
+ status: inspection.streaming || incomplete ? 200 : 502, headers,
203
+ }));
204
+ }
205
+
206
+ export async function guardOpenGatewayCompletion(response: Response, options: OpenGatewayCompletionGuardOptions): Promise<Response> {
207
+ if (options.url !== "https://apis.opengateway.ai/v1/responses" || response.status !== 200) return response;
208
+ let body: unknown;
209
+ try { body = typeof options.body === "string" ? JSON.parse(options.body) : options.body; }
210
+ catch { return response; }
211
+ if (!isPlainObject(body)) return response;
212
+ const canReplay = options.retryEnabled !== false && process.env[EMPTY_COMPLETION_RETRY_ENV] !== "0"
213
+ && selfContainedResponsesBody(body) && !isNonReplayableResponse(response);
214
+ let selected = response;
215
+ for (let attempt = 0; ; attempt++) {
216
+ const inspected = await inspect(selected, options);
217
+ if (inspected.kind === "pass") return inspected.response;
218
+ const usage = inspected.snapshot.usage;
219
+ options.recordUsage(usage);
220
+ if (inspected.kind === "invalid") return terminalResponse(selected, inspected, "invalid_completion");
221
+ if (typeof body.max_output_tokens === "number" && body.max_output_tokens > 0
222
+ && isPlainObject(usage) && typeof usage.output_tokens === "number" && usage.output_tokens >= body.max_output_tokens) {
223
+ return terminalResponse(selected, inspected, "max_output_tokens");
224
+ }
225
+ if (attempt > 0 || !canReplay || options.signal.aborted) {
226
+ return terminalResponse(selected, inspected, attempt > 0
227
+ ? "empty_completion_retry_failed" : "empty_completion");
228
+ }
229
+ selected = await options.retry();
230
+ if (selected.status !== 200) return selected;
231
+ }
232
+ }
@@ -0,0 +1,41 @@
1
+ import type { AdapterRequest } from "../../adapters/base";
2
+ import { formatErrorResponse } from "../../bridge";
3
+ import { resolveStallTimeoutMs } from "../../stall-timeout";
4
+ import type { OcxConfig } from "../../types";
5
+ import { recordKeyWireAttemptUsage, usageFromResponsesPayload, type RequestLogContext } from "../request-log";
6
+ import { isPlainObject } from "../responses-snapshot-codec";
7
+ import { guardOpenGatewayCompletion } from "./opengateway-completion-guard";
8
+
9
+ export function parseOutboundRequestBody(bodyText: string): Record<string, unknown> | undefined {
10
+ try {
11
+ const body: unknown = JSON.parse(bodyText);
12
+ return isPlainObject(body) ? body : undefined;
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ }
17
+
18
+ export function guardPassthroughCompletion(
19
+ response: Response,
20
+ request: AdapterRequest,
21
+ config: Pick<OcxConfig, "emptyCompletionRetry" | "stallTimeoutSec">,
22
+ signal: AbortSignal,
23
+ logCtx: RequestLogContext,
24
+ rebuildAndRefetch: (kind: "empty-completion") => Promise<Response | { failed: Response | { kind: "adapter-handoff" } }>,
25
+ ): Promise<Response> {
26
+ if (request.url !== "https://apis.opengateway.ai/v1/responses") return Promise.resolve(response);
27
+ return guardOpenGatewayCompletion(response, {
28
+ url: request.url,
29
+ body: parseOutboundRequestBody(request.body),
30
+ signal,
31
+ retryEnabled: config.emptyCompletionRetry,
32
+ stallTimeoutMs: resolveStallTimeoutMs(config.stallTimeoutSec),
33
+ recordUsage: usage => { recordKeyWireAttemptUsage(logCtx, usageFromResponsesPayload(usage)); },
34
+ retry: async () => {
35
+ const result = await rebuildAndRefetch("empty-completion");
36
+ if (!("failed" in result)) return result;
37
+ return result.failed instanceof Response ? result.failed
38
+ : formatErrorResponse(502, "upstream_error", "Native recovery changed the provider wire");
39
+ },
40
+ });
41
+ }
@@ -1,3 +1,4 @@
1
+ import { wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
1
2
  import { isNativeControlResponse } from "./native-response-control";
2
3
  import { createOutboundCredentialMask, createTerminalErrorRedactionBlockRewrite } from "./terminal-error-redaction";
3
4
  import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
@@ -36,6 +37,7 @@ import type { ResponsesTerminalStatus } from "../../bridge";
36
37
  import { isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse, isCodexWsPreludeProjection } from "./ws-upstream";
37
38
  import { recordSubagentQuotaFailureForThreadSpawn } from "../../codex/subagent-model-fallback";
38
39
  import { recordCodexUpstreamOutcome } from "../../codex/routing";
40
+ import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
39
41
  import { codexProbeLeaseId, codexProbeQuotaScope, codexTransientProbeGrant, releaseCodexAuthContextProbeLease } from "../../codex/auth-context";
40
42
  import { consumeComboFailure } from "./core-combo-failure";
41
43
  import { clientCancelledResponse, readDisplaySafeErrorText } from "./core-errors";
@@ -527,13 +529,14 @@ export async function deliverPassthroughResponse(
527
529
  } else if (!shouldDeferCodexResetDerivedCooldown(
528
530
  upstreamResponse,
529
531
  options.deferCodexResetDerivedCooldown,
530
- ) && !isReplayRefusalResponse(upstreamResponse)) {
532
+ ) && !isReplayRefusalResponse(upstreamResponse) && !wasPermanentAccountRefusal(upstreamResponse)) {
531
533
  // A refusal this proxy made is not evidence about the account. Recording it would
532
534
  // classify the synthetic 429 as quota exhaustion and write a default cooldown against
533
535
  // a credential the request may never have reached, and that false signal outlives the
534
536
  // request. The sibling recorders on this path already decline: the terminal recorder
535
537
  // needs an ok streaming body, and the quota-header snapshot finds no quota headers.
536
538
  recordCodexUpstreamOutcome(config, admissionState.authCtx.accountId, upstreamResponse.status, {
539
+ ...codexFailureWindowOutcomeMeta(admissionState.authCtx),
537
540
  ...quotaMeta,
538
541
  threadId: admissionState.authCtx.affinityKey,
539
542
  fixedAccount: admissionState.authCtx.fixedAccount,
@@ -572,7 +575,7 @@ export async function deliverPassthroughResponse(
572
575
  // through sanitizePassthroughHeaders) so a redirect to a dead host can never
573
576
  // masquerade as a pre-connection failure after the credential was seen.
574
577
  // The numeric outcome above already classified it neutral — no streak.
575
- if (isNonReplayableResponse(upstreamResponse) && !isReplayRefusalResponse(upstreamResponse)) {
578
+ if (isNonReplayableResponse(upstreamResponse) && !isReplayRefusalResponse(upstreamResponse) && !wasPermanentAccountRefusal(upstreamResponse)) {
576
579
  return await sanitizeNonReplayableUpstreamError(upstreamResponse, upstream.signal);
577
580
  }
578
581
  if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {
@@ -1,3 +1,5 @@
1
+ import { recoverAutoPausedAccount, rotatePassthroughKeyOn429 } from "./passthrough-key-rotation";
2
+ import { adoptCodexWsStage, capturePassthroughAffinity } from "./passthrough-observations";
1
3
  import { spendLedgerStorageErrorResponse } from "./spend-storage-error";
2
4
  import { requestPacingOverloadResponse } from "./pacing-overload";
3
5
  import { recordDistributedResponseFailure } from "../../providers/api-key-response-ownership";
@@ -51,6 +53,7 @@ import {
51
53
  import { restoreRoutedCustomCalls, RoutedCustomToolCompatError } from "../../responses/custom-tool-compat";
52
54
  import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema";
53
55
  import { MuseToolChoiceCompatibilityError } from "../../adapters/openai-responses/muse-tool-choice";
56
+ import { OpenGatewayInputCompatibilityError } from "../../adapters/openai-responses/opengateway-input";
54
57
  import { formatErrorResponse } from "../../bridge";
55
58
  import { redactSecretString } from "../../lib/redact";
56
59
  import {
@@ -99,7 +102,6 @@ import {
99
102
  upstreamHostCircuitOpenResponse,
100
103
  usesCodexForwardPoolAuth,
101
104
  codexWsQuotaObserver,
102
- isFixedCodexAccount,
103
105
  codexPoolAccountModel400Denial,
104
106
  shouldRetryCodexPoolAccountQuota,
105
107
  shouldRetryCodexPoolAccountTransient,
@@ -111,7 +113,7 @@ import {
111
113
  clearCodexModelDenialEvidence,
112
114
  recordCodexModelDenialEvidence,
113
115
  } from "../../codex/model-entitlements";
114
- import { codexWsSocketDeathStage, isCodexWsUpstreamResponse, readCodexWsStage } from "./codex-ws-wire";
116
+ import { codexWsSocketDeathStage, isCodexWsUpstreamResponse } from "./codex-ws-wire";
115
117
  import { linkAbortSignal } from "./core-lifetime";
116
118
  import type { CodexAuthContext } from "../../codex/auth-context";
117
119
  import { checkOutboundBodySize, describeOutboundBodyRefusal } from "./outbound-body-guard";
@@ -130,7 +132,8 @@ import {
130
132
  } from "../../lib/upstream-retry";
131
133
  import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
132
134
  import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
133
- import { recordCodexUpstreamOutcome } from "../../codex/routing";
135
+ import { captureAccountAutoPauseAttempt, wasAccountAutoPaused, wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
136
+ import { codexFailureWindowOutcomeMeta, recordCodexUpstreamOutcome } from "../../codex/routing";
134
137
  import { describeUpstreamConnectFailure } from "./upstream-error";
135
138
  import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
136
139
  import {
@@ -152,7 +155,6 @@ import {
152
155
  rotateGenericOAuthAccountOn429,
153
156
  failoverAccountSnapshot,
154
157
  } from "../../oauth/generic-account-failover";
155
- import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug";
156
158
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
157
159
  import {
158
160
  attemptOpaqueBlobRecovery,
@@ -171,6 +173,7 @@ import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION }
171
173
  import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit";
172
174
  import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata";
173
175
  import { unboundPoolSpendRefusalResponse } from "../workflow-refusal";
176
+ import { guardPassthroughCompletion, parseOutboundRequestBody } from "./passthrough-completion";
174
177
 
175
178
  /** Transfer an uncommitted Copilot exchange to the existing translated pipeline without new admission. */
176
179
  export interface PassthroughAdapterHandoff { kind: "adapter-handoff" }
@@ -201,6 +204,7 @@ export async function preparePassthroughExchange(
201
204
  | "genericFailoverAccountId"
202
205
  | "passiveQuotaWriterGeneration"
203
206
  | "oauthDispatch"
207
+ | "recoverAutoPausedSelection"
204
208
  | "resolveSelectionAdapter"
205
209
  | "resolveCopilotSelection"
206
210
  | "copilotRefusalResponse"
@@ -384,6 +388,7 @@ export async function preparePassthroughExchange(
384
388
  || error instanceof XaiToolSchemaCompatibilityError
385
389
  || error instanceof RoutedCustomToolCompatError
386
390
  || error instanceof MuseToolChoiceCompatibilityError
391
+ || error instanceof OpenGatewayInputCompatibilityError
387
392
  ) {
388
393
  return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message));
389
394
  }
@@ -432,16 +437,6 @@ export async function preparePassthroughExchange(
432
437
  // `{model, input, stream}` with no tools and Copilot answers with a `custom_tool_call` for
433
438
  // `apply_patch`. Policing an absent catalog truncates that turn. An unreadable body lands there
434
439
  // too because the proxy cannot establish the caller's declared authorization boundary.
435
- const parseOutboundRequestBody = (bodyText: string): Record<string, unknown> | undefined => {
436
- try {
437
- const body = JSON.parse(bodyText) as unknown;
438
- return body && typeof body === "object" && !Array.isArray(body)
439
- ? body as Record<string, unknown>
440
- : undefined;
441
- } catch {
442
- return undefined;
443
- }
444
- };
445
440
  let outboundRequestBody: Record<string, unknown> | undefined;
446
441
  const declaredWireToolNames = new Set<string>();
447
442
  const declaredBareWireToolNames = new Set<string>();
@@ -733,22 +728,11 @@ export async function preparePassthroughExchange(
733
728
  }
734
729
  nativeHostState.lease = null;
735
730
  };
736
- /**
737
- * #4191: a Codex WS exchange pins its content-free stage record on the
738
- * Response it resolves (markCodexWsStage). Adopting the record here, at
739
- * the single funnel every physical upstream response passes through,
740
- * binds it to the attempt that actually served it — including the 502/504
741
- * pre-response JSON settles that never reach the SSE relay.
742
- */
743
- const adoptCodexWsStage = (response: Response): void => {
744
- const stage = readCodexWsStage(response);
745
- if (stage && logCtx.activeAttempt) logCtx.activeAttempt.codexWsStage = stage;
746
- };
747
731
  let upstreamResponse: Response, lastDistributedResponse: Response | undefined;
748
732
  const adoptObservedResponse = <T extends Response>(response: T): T => {
749
733
  if (!isNonReplayableResponse(response)) lastDistributedResponse = response.status === 401 || response.status === 429 ? response : undefined;
750
734
  settleObservedHostResponse();
751
- adoptCodexWsStage(response);
735
+ adoptCodexWsStage(response, logCtx);
752
736
  return response;
753
737
  };
754
738
  let passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
@@ -999,6 +983,7 @@ export async function preparePassthroughExchange(
999
983
  }
1000
984
  if (usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)) {
1001
985
  recordCodexUpstreamOutcome(config, admissionState.authCtx.accountId, outcome, {
986
+ ...codexFailureWindowOutcomeMeta(admissionState.authCtx),
1002
987
  threadId: admissionState.authCtx.affinityKey,
1003
988
  fixedAccount: admissionState.authCtx.fixedAccount,
1004
989
  modelId: route.modelId,
@@ -1050,6 +1035,7 @@ export async function preparePassthroughExchange(
1050
1035
  providerName: route.providerName,
1051
1036
  modelId: route.modelId,
1052
1037
  onPhysicalDispatch: receiptMode ? sendBudgetState.noteInitialDispatch : undefined,
1038
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
1053
1039
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1054
1040
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1055
1041
  ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
@@ -1165,6 +1151,7 @@ export async function preparePassthroughExchange(
1165
1151
  `${route.providerName}|${route.modelId}|${recovery}`,
1166
1152
  { allowFinalRecoveryReserve: transientSendPolicy() === null },
1167
1153
  );
1154
+ if (recovery === "empty-completion") allowance.attempts = Math.min(1, allowance.attempts);
1168
1155
  try {
1169
1156
  return await fetchWithTransientRetry(
1170
1157
  innerRecovery => {
@@ -1187,6 +1174,7 @@ export async function preparePassthroughExchange(
1187
1174
  dispatchOverride: oauthDispatch(request, parsed, "passthrough"),
1188
1175
  providerName: route.providerName,
1189
1176
  modelId: route.modelId,
1177
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
1190
1178
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1191
1179
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1192
1180
  ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
@@ -1195,7 +1183,8 @@ export async function preparePassthroughExchange(
1195
1183
  .then(adoptObservedResponse);
1196
1184
  },
1197
1185
  { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts,
1198
- onSendsConsumed: transientSendReporter(allowance.permit), claimAmbiguousResend: claimPreHeaderResend },
1186
+ onSendsConsumed: transientSendReporter(allowance.permit),
1187
+ claimAmbiguousResend: recovery === "empty-completion" ? undefined : claimPreHeaderResend },
1199
1188
  );
1200
1189
  } catch (err) {
1201
1190
  return { failed: transportFailureResponse(err) };
@@ -1209,8 +1198,18 @@ export async function preparePassthroughExchange(
1209
1198
 
1210
1199
  // Keep recovery kinds in sync with the generic `recovery:` loop below.
1211
1200
  passthroughRecovery: for (;;) {
1201
+ if (wasAccountAutoPaused(upstreamResponse) && route.provider.authMode !== "forward") {
1202
+ const recovered = await recoverAutoPausedAccount({ requestKey: transportState, reserve: reserveCredentialHop,
1203
+ remaining: transientSendCapFor(transientRetryPolicyFor(route.provider)?.attempts, sendBudgetState.targetSendsUsed),
1204
+ refresh: () => transportState.recoverAutoPausedSelection(parsed), refetch: () => rebuildAndRefetch("oauth-account-429"), setPermit: permit => { sendBudgetState.pendingHopPermit = permit; } });
1205
+ if (!recovered) break;
1206
+ if ("failed" in recovered) return recovered.failed;
1207
+ upstreamResponse = recovered; continue passthroughRecovery;
1208
+ }
1209
+
1210
+
1212
1211
  if (
1213
- upstreamResponse.status === 401
1212
+ upstreamResponse.status === 401 && !wasPermanentAccountRefusal(upstreamResponse)
1214
1213
  && (admissionState.authCtx.kind === "main-pool" || admissionState.authCtx.kind === "pool")
1215
1214
  && usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)
1216
1215
  && codex401ReplayKind === null
@@ -1293,6 +1292,7 @@ export async function preparePassthroughExchange(
1293
1292
  dispatchOverride: oauthDispatch(request, parsed, "passthrough"),
1294
1293
  providerName: route.providerName,
1295
1294
  modelId: route.modelId,
1295
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
1296
1296
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1297
1297
  beforeDispatch: headers => {
1298
1298
  replayGuard?.(headers);
@@ -1336,14 +1336,14 @@ export async function preparePassthroughExchange(
1336
1336
  continue passthroughRecovery;
1337
1337
  }
1338
1338
 
1339
- if (codex401ReplayKind !== null && upstreamResponse.status === 401) break;
1339
+ if (codex401ReplayKind !== null && upstreamResponse.status === 401 && !wasPermanentAccountRefusal(upstreamResponse)) break;
1340
1340
 
1341
1341
  // Native Responses providers return before the generic adapter recovery loop below. Keep
1342
1342
  // their OAuth contract identical: one pre-stream 401 forces a credential refresh and one
1343
1343
  // rebuilt replay. xAI's current subscription models use this branch now that their official
1344
1344
  // Grok CLI catalog declares the Responses backend.
1345
1345
  if (
1346
- upstreamResponse.status === 401
1346
+ upstreamResponse.status === 401 && !wasPermanentAccountRefusal(upstreamResponse)
1347
1347
  && isOAuth401ReplayProvider
1348
1348
  && transportState.sentOAuthSnapshot
1349
1349
  && !oauth401ReplayAttempted
@@ -1440,6 +1440,7 @@ export async function preparePassthroughExchange(
1440
1440
  dispatchOverride: oauthDispatch(request, parsed, "passthrough"),
1441
1441
  providerName: route.providerName,
1442
1442
  modelId: route.modelId,
1443
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
1443
1444
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1444
1445
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1445
1446
  ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
@@ -1586,6 +1587,7 @@ export async function preparePassthroughExchange(
1586
1587
  dispatchOverride: oauthDispatch(request, parsed, "passthrough"),
1587
1588
  providerName: route.providerName,
1588
1589
  modelId: route.modelId,
1590
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
1589
1591
  onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
1590
1592
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1591
1593
  ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
@@ -1600,6 +1602,7 @@ export async function preparePassthroughExchange(
1600
1602
  return transportFailureResponse(err);
1601
1603
  }
1602
1604
  }
1605
+ const keyRotation = await rotatePassthroughKeyOn429({ config, route, parsed, response: upstreamResponse, adapterName: transportState.adapter.name, budgetSpent: () => sendBudgetExhausted(transientSendAttempts()), refetch: () => rebuildAndRefetch("key-429") }); if (keyRotation) { if ("failed" in keyRotation) return keyRotation.failed; upstreamResponse = keyRotation; continue passthroughRecovery; }
1603
1606
  const captureAffinityResponse = (
1604
1607
  response: Response,
1605
1608
  captureAuthCtx: CodexAuthContext = admissionState.authCtx,
@@ -1608,18 +1611,7 @@ export async function preparePassthroughExchange(
1608
1611
  || captureAuthCtx.kind === "pool"
1609
1612
  || captureAuthCtx.kind === "main-pool",
1610
1613
  ): void => {
1611
- if (!isCanonicalOpenAiForwardProvider(route.provider)) return;
1612
- captureCodexAffinityDiagnostic({
1613
- inboundHeaders: req.headers,
1614
- outboundHeaders: captureRequest.headers,
1615
- authKind: captureAuthCtx.kind,
1616
- accountMode: route.codexAccountMode,
1617
- fixedAccount: isFixedCodexAccount(captureAuthCtx),
1618
- credentialSubstituted,
1619
- accountGatedModel: ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId),
1620
- wireModelNormalized: parsed.modelId !== route.modelId,
1621
- status: response.status,
1622
- });
1614
+ capturePassthroughAffinity(response, req.headers, captureRequest.headers, captureAuthCtx, route, parsed.modelId, credentialSubstituted);
1623
1615
  };
1624
1616
  recordDistributedResponseFailure(lastDistributedResponse ?? upstreamResponse, config, route.providerName, options.abortSignal);
1625
1617
  captureAffinityResponse(upstreamResponse);
@@ -1646,7 +1638,9 @@ export async function preparePassthroughExchange(
1646
1638
  options.abortSignal,
1647
1639
  parsed.modelId,
1648
1640
  );
1649
- if (model400Denial !== undefined) {
1641
+ if (wasAccountAutoPaused(upstreamResponse)) {
1642
+ poolRetryOutcome = upstreamResponse.status;
1643
+ } else if (model400Denial !== undefined) {
1650
1644
  // Spend this refusal on more than one retry. It is the account's own authenticated
1651
1645
  // answer about this model, and the roster cache that selection otherwise reads expires
1652
1646
  // five minutes after a catalog sync fills it -- so without remembering this, the next
@@ -1679,7 +1673,7 @@ export async function preparePassthroughExchange(
1679
1673
  // ladder does have one — retrying the account the refreshed roster still grants — and
1680
1674
  // keeps it. An earlier revision also broke here on a non-400 outcome, which no test could
1681
1675
  // justify because this flag already produced the identical result.
1682
- const storedReplaySpent = codex401ReplayKind === "stored";
1676
+ const storedReplaySpent = codex401ReplayKind === "stored" && !wasAccountAutoPaused(upstreamResponse);
1683
1677
  const retry = await retryCodexPoolOnAlternateAccount({
1684
1678
  callerAuthHeaders,
1685
1679
  config,
@@ -1698,7 +1692,7 @@ export async function preparePassthroughExchange(
1698
1692
  httpOnly: canonicalBufferedJson,
1699
1693
  preOutputRefusalHold: preOutputRefusalHold(),
1700
1694
  onResponse: (response, retryAuthCtx, retryRequest) => {
1701
- adoptCodexWsStage(response);
1695
+ adoptCodexWsStage(response, logCtx);
1702
1696
  captureAffinityResponse(
1703
1697
  response,
1704
1698
  retryAuthCtx,
@@ -1945,6 +1939,9 @@ export async function preparePassthroughExchange(
1945
1939
  break;
1946
1940
  }
1947
1941
 
1942
+ upstreamResponse = await guardPassthroughCompletion(
1943
+ upstreamResponse, request, config, upstream.signal, logCtx, rebuildAndRefetch,
1944
+ );
1948
1945
  // Where this relay dials upstream. Local infrastructure (loopback / private / `.local` / `.lan`)
1949
1946
  // is operator-trusted and its silent phases are normal, so an unset stall budget resolves to
1950
1947
  // disabled for it. Provider rotation above keeps the same endpoint origin, so the routed
@@ -0,0 +1,40 @@
1
+ import { isNonReplayableResponse } from "../../lib/upstream-retry";
2
+ import { hasKeyPoolFailover, rotateProviderTransportOn429 } from "../../providers/key-failover";
3
+ import type { RouteResult } from "../../router";
4
+ import type { OcxConfig, OcxParsedRequest } from "../../types";
5
+ import { bindRouteReasoningReplayScope } from "./core-replay";
6
+
7
+ // Passthrough's credential moves share one import site so passthrough-dispatch.ts stays under its line cap.
8
+ export { recoverAutoPausedAccount } from "./account-auto-pause-recovery";
9
+
10
+ /**
11
+ * Key-pool failover for a native passthrough 429 before any output. This wire returns before the
12
+ * adapter loop's key-429 arm, so without it a pooled key's 429 reached the client while the same
13
+ * refusal rotates on the adapter and chat-native wires. Distributed (round-robin) selection already
14
+ * cools the key on the send and deliberately answers without a replay. A synthesized replay refusal
15
+ * is not a provider rate limit and must not cool a key. A spent send budget is checked before any
16
+ * key is cooled, so it keeps the real 429 body. Returns undefined when nothing rotated.
17
+ */
18
+ export async function rotatePassthroughKeyOn429<T>(options: {
19
+ config: OcxConfig;
20
+ route: Pick<RouteResult, "provider" | "providerName">;
21
+ parsed: OcxParsedRequest;
22
+ response: Response;
23
+ adapterName: string;
24
+ budgetSpent: () => boolean;
25
+ refetch: () => Promise<T>;
26
+ }): Promise<T | undefined> {
27
+ const { config, route, parsed, response, adapterName, refetch } = options;
28
+ if (response.status !== 429 || isNonReplayableResponse(response) || !hasKeyPoolFailover(route.provider)
29
+ || route.provider._apiKeyAttempt?.distributed || options.budgetSpent()) return;
30
+ const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
31
+ retryAfter: response.headers.get("retry-after"),
32
+ attemptedKey: route.provider.apiKey,
33
+ promptCacheKey: parsed.options.promptCacheKey,
34
+ });
35
+ if (!rotated) return;
36
+ try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
37
+ route.provider = rotated;
38
+ bindRouteReasoningReplayScope({ parsed, providerName: route.providerName, provider: rotated, adapterName });
39
+ return refetch();
40
+ }