@bitkyc08/opencodex 2.89.0 → 2.91.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 (242) hide show
  1. package/gui/dist/assets/App-DMps4OLk.css +1 -0
  2. package/gui/dist/assets/App-nZ34cR5G.js +58 -0
  3. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
  4. package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
  5. package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/package.json +2 -2
  8. package/src/adapters/anthropic.ts +2 -5
  9. package/src/adapters/base.ts +4 -0
  10. package/src/adapters/claude-cli/adapter.ts +13 -7
  11. package/src/adapters/claude-cli/usage-admission.ts +28 -2
  12. package/src/adapters/coding-agent/turn.ts +14 -4
  13. package/src/adapters/cursor/protobuf-request.ts +3 -0
  14. package/src/adapters/google-progress-history.ts +19 -1
  15. package/src/adapters/google.ts +28 -9
  16. package/src/adapters/image.ts +5 -3
  17. package/src/adapters/input-media-guard.ts +5 -2
  18. package/src/adapters/kiro-refusal.ts +41 -1
  19. package/src/adapters/ollama-native.ts +1 -0
  20. package/src/adapters/openai-chat/messages.ts +34 -4
  21. package/src/adapters/openai-chat/passthrough.ts +37 -4
  22. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  23. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  24. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  25. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  26. package/src/adapters/openai-chat.ts +7 -6
  27. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  28. package/src/adapters/openai-responses/passthrough.ts +7 -2
  29. package/src/adapters/openai-responses/request-strips.ts +32 -0
  30. package/src/adapters/openai-responses/transport.ts +19 -1
  31. package/src/chat/inbound.ts +20 -2
  32. package/src/chat/outbound.ts +36 -4
  33. package/src/chat/reasoning-details.ts +137 -0
  34. package/src/claude/desktop-3p-library.ts +43 -3
  35. package/src/claude/desktop-3p.ts +20 -10
  36. package/src/claude/desktop-picker-profile.ts +18 -9
  37. package/src/claude/desktop-remote-store-io.ts +2 -2
  38. package/src/cli/capabilities-agents-routing.ts +50 -0
  39. package/src/cli/capabilities-base.ts +15 -2
  40. package/src/cli/capabilities-observe-system.ts +4 -4
  41. package/src/cli/codex-shim-autorestore.ts +5 -0
  42. package/src/cli/dispatch.ts +30 -0
  43. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  44. package/src/cli/fallback.ts +127 -0
  45. package/src/cli/help.ts +1 -0
  46. package/src/cli/index.ts +1 -2
  47. package/src/cli/link-apply-restart.ts +72 -0
  48. package/src/cli/link-child.ts +6 -2
  49. package/src/cli/link-cleanup.ts +95 -0
  50. package/src/cli/link-enrollment.ts +4 -0
  51. package/src/cli/link.ts +84 -9
  52. package/src/cli/provider.ts +44 -2
  53. package/src/cli/registry.ts +13 -1
  54. package/src/cli/restart-failure.ts +4 -0
  55. package/src/cli/runtime-api.ts +1 -1
  56. package/src/cli/status-codex-settings.ts +56 -0
  57. package/src/cli/status-fallback.ts +29 -0
  58. package/src/cli/status-install.ts +52 -0
  59. package/src/cli/status.ts +20 -1
  60. package/src/cli/system-restart-client.ts +32 -1
  61. package/src/cli/update-restart-child.ts +4 -3
  62. package/src/cli/update-restart-home.ts +20 -8
  63. package/src/client/connect.ts +82 -6
  64. package/src/client/home-unreachable.ts +16 -0
  65. package/src/client/hub-relay.ts +8 -2
  66. package/src/client/link-home.ts +119 -6
  67. package/src/client/link-parity-reporter.ts +107 -0
  68. package/src/client/link-relay-transport.ts +8 -1
  69. package/src/client/link-relay.ts +27 -3
  70. package/src/client/machine-listener.ts +83 -1
  71. package/src/client/runtime-restart.ts +127 -0
  72. package/src/client/runtime.ts +137 -25
  73. package/src/codex/account-lifecycle.ts +19 -6
  74. package/src/codex/account-store.ts +27 -8
  75. package/src/codex/account-usability.ts +4 -1
  76. package/src/codex/auth-api/account-list.ts +10 -5
  77. package/src/codex/auth-api/account-pause-group.ts +2 -22
  78. package/src/codex/auth-api/login-collision.ts +59 -0
  79. package/src/codex/auth-api/login-flow.ts +12 -7
  80. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  81. package/src/codex/auth-collision.ts +31 -4
  82. package/src/codex/auth-context.ts +58 -12
  83. package/src/codex/catalog/parsing.ts +4 -0
  84. package/src/codex/cli-update-plan.ts +18 -3
  85. package/src/codex/collision-identity.ts +78 -0
  86. package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
  87. package/src/codex/main-account-hard-lock.ts +14 -0
  88. package/src/codex/quota-history.ts +7 -0
  89. package/src/codex/quota.ts +10 -0
  90. package/src/codex/routing/cooldown-math.ts +5 -0
  91. package/src/codex/routing/failure-window.ts +61 -9
  92. package/src/codex/routing.ts +4 -3
  93. package/src/codex/shim-diagnostics.ts +2 -1
  94. package/src/codex/shim-fingerprint.ts +11 -2
  95. package/src/codex/shim-templates.ts +16 -5
  96. package/src/codex/shim-windows-refresh.ts +224 -0
  97. package/src/codex/shim.ts +45 -21
  98. package/src/codex/subagent-model-fallback.ts +24 -1
  99. package/src/combos/failover.ts +8 -1
  100. package/src/combos/index.ts +1 -0
  101. package/src/combos/resolve.ts +12 -0
  102. package/src/config/diagnostics.ts +5 -0
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/provider-validation.ts +7 -2
  105. package/src/config/schema/config-schema.ts +3 -0
  106. package/src/config/schema/fallback-policy.ts +40 -0
  107. package/src/generated/compatibility-version.json +360 -192
  108. package/src/generated/model-metadata.ts +1 -1
  109. package/src/images/loop.ts +2 -2
  110. package/src/lib/authorization-scheme.ts +95 -0
  111. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  112. package/src/lib/provider-admission-wait.ts +14 -0
  113. package/src/lib/provider-client-headers.ts +1 -0
  114. package/src/lib/redact.ts +2 -2
  115. package/src/lib/service-secrets.ts +37 -2
  116. package/src/lib/system-restart-contract.ts +24 -0
  117. package/src/lib/test-home-guard.ts +78 -11
  118. package/src/lib/translator-budget.ts +1 -1
  119. package/src/lib/upstream-retry.ts +15 -0
  120. package/src/link/child-parity.ts +124 -0
  121. package/src/link/codex-settings.ts +37 -0
  122. package/src/link/compatibility.ts +119 -0
  123. package/src/link/management-routes.ts +2 -0
  124. package/src/link/peer-report.ts +100 -0
  125. package/src/link/remote-platform.ts +78 -0
  126. package/src/link/ssh-argv.ts +19 -0
  127. package/src/link/status-projection.ts +24 -2
  128. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  129. package/src/oauth/account-import/parser.ts +15 -2
  130. package/src/oauth/account-import/types.ts +7 -0
  131. package/src/oauth/chatgpt-shared.ts +10 -1
  132. package/src/oauth/dsh.ts +45 -2
  133. package/src/oauth/index.ts +1 -1
  134. package/src/oauth/kimi.ts +2 -0
  135. package/src/oauth/kiro.ts +44 -1
  136. package/src/oauth/login-flow-state.ts +11 -2
  137. package/src/oauth/nous.ts +4 -1
  138. package/src/oauth/store.ts +2 -1
  139. package/src/protocols/codecs/chat.ts +3 -3
  140. package/src/protocols/encoders/adapter-events.ts +26 -2
  141. package/src/protocols/encoders/chat.ts +55 -3
  142. package/src/providers/claude-cli-usage.ts +20 -5
  143. package/src/providers/initial-model-selection.ts +2 -2
  144. package/src/providers/key-failover.ts +10 -2
  145. package/src/providers/key-store.ts +8 -3
  146. package/src/providers/mistral-transport.ts +240 -0
  147. package/src/providers/openai-sidecar.ts +5 -0
  148. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  149. package/src/providers/quota/antigravity.ts +8 -10
  150. package/src/providers/quota/failure-codes.ts +18 -0
  151. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  152. package/src/providers/quota-routing-cache.ts +1 -1
  153. package/src/providers/quota.ts +5 -1
  154. package/src/providers/registry/entries-extended.ts +6 -1
  155. package/src/providers/routed-config.ts +1 -1
  156. package/src/providers/xai-transport.ts +2 -1
  157. package/src/quota-pool/assemble.ts +326 -0
  158. package/src/quota-pool/contract.ts +58 -0
  159. package/src/quota-pool/identity.ts +203 -0
  160. package/src/remote/features.ts +74 -0
  161. package/src/remote/protocol.ts +2 -0
  162. package/src/remote/served-by.ts +57 -0
  163. package/src/responses/audio.ts +46 -0
  164. package/src/responses/input-media.ts +37 -15
  165. package/src/responses/parser-content.ts +22 -27
  166. package/src/responses/parser.ts +7 -0
  167. package/src/responses/reasoning-envelope.ts +12 -0
  168. package/src/responses/reasoning-retention.ts +218 -11
  169. package/src/responses/schema.ts +3 -4
  170. package/src/responses/web-search-replay.ts +11 -2
  171. package/src/routing/fallback-policy.ts +290 -0
  172. package/src/routing/fallback-state.ts +179 -0
  173. package/src/server/audio-client.ts +2 -1
  174. package/src/server/audio-dictation.ts +2 -1
  175. package/src/server/audio-upstream.ts +2 -1
  176. package/src/server/auth-cors.ts +6 -5
  177. package/src/server/background-lifecycle.ts +4 -0
  178. package/src/server/chat-completions.ts +8 -1
  179. package/src/server/chat-native.ts +8 -7
  180. package/src/server/gui-session.ts +2 -1
  181. package/src/server/index/link-listener.ts +11 -1
  182. package/src/server/index/link-management.ts +47 -0
  183. package/src/server/index/link-relay-sessions.ts +11 -2
  184. package/src/server/index/optional-listeners.ts +7 -2
  185. package/src/server/index/serve-options.ts +27 -4
  186. package/src/server/index/websocket-handler.ts +10 -0
  187. package/src/server/lifecycle.ts +17 -1
  188. package/src/server/live.ts +5 -0
  189. package/src/server/management/context.ts +1 -1
  190. package/src/server/management/fallback-routes.ts +75 -0
  191. package/src/server/management/link-enrollment.ts +21 -3
  192. package/src/server/management/link-route-state.ts +2 -0
  193. package/src/server/management/link-routes.ts +12 -1
  194. package/src/server/management/oauth-account-routes.ts +2 -1
  195. package/src/server/management/quota-pool-routes.ts +24 -0
  196. package/src/server/management/route-registry.ts +3 -0
  197. package/src/server/management-api.ts +16 -0
  198. package/src/server/management-auth.ts +2 -1
  199. package/src/server/messages-native.ts +11 -0
  200. package/src/server/request-log.ts +16 -7
  201. package/src/server/responses/adapter-continuation.ts +2 -2
  202. package/src/server/responses/adapter-dispatch.ts +2 -2
  203. package/src/server/responses/agent-task-recovery.ts +1 -1
  204. package/src/server/responses/codex-ws-exchange.ts +3 -0
  205. package/src/server/responses/codex-ws-metadata.ts +44 -0
  206. package/src/server/responses/codex-ws-session.ts +21 -1
  207. package/src/server/responses/combo-stream-preflight.ts +7 -2
  208. package/src/server/responses/compact.ts +4 -2
  209. package/src/server/responses/core-auth.ts +31 -1
  210. package/src/server/responses/core-codex-account.ts +21 -14
  211. package/src/server/responses/core-combo.ts +75 -1
  212. package/src/server/responses/core-opaque-recovery.ts +20 -11
  213. package/src/server/responses/deepseek-image-files.ts +121 -0
  214. package/src/server/responses/empty-completion-guard.ts +8 -0
  215. package/src/server/responses/encrypted-payload.ts +3 -3
  216. package/src/server/responses/fetch-helpers.ts +30 -10
  217. package/src/server/responses/input-admission.ts +6 -2
  218. package/src/server/responses/main-hard-lock-routing.ts +85 -8
  219. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  220. package/src/server/responses/passthrough-completion.ts +41 -0
  221. package/src/server/responses/passthrough-delivery.ts +2 -0
  222. package/src/server/responses/passthrough-dispatch.ts +13 -13
  223. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  224. package/src/server/responses/policy-fallback.ts +44 -1
  225. package/src/server/responses/reasoning-archive-error.ts +21 -0
  226. package/src/server/responses/request-prepare.ts +19 -1
  227. package/src/server/responses/request-transport.ts +61 -3
  228. package/src/server/restart-replacement.ts +50 -2
  229. package/src/server/ws-bridge.ts +5 -0
  230. package/src/service/cli.ts +25 -2
  231. package/src/service/diagnostics.ts +20 -6
  232. package/src/service/orchestration.ts +17 -0
  233. package/src/service/status-json.ts +116 -0
  234. package/src/types/config.ts +7 -0
  235. package/src/types/fallback.ts +38 -0
  236. package/src/types/provider.ts +2 -0
  237. package/src/types/request.ts +16 -1
  238. package/src/types.ts +1 -0
  239. package/src/web-search/loop.ts +2 -2
  240. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  241. package/gui/dist/assets/App-dnkJ85te.css +0 -1
  242. package/gui/dist/assets/quota-summary-BY_10IEz.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
+ }
@@ -37,6 +37,7 @@ import type { ResponsesTerminalStatus } from "../../bridge";
37
37
  import { isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse, isCodexWsPreludeProjection } from "./ws-upstream";
38
38
  import { recordSubagentQuotaFailureForThreadSpawn } from "../../codex/subagent-model-fallback";
39
39
  import { recordCodexUpstreamOutcome } from "../../codex/routing";
40
+ import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
40
41
  import { codexProbeLeaseId, codexProbeQuotaScope, codexTransientProbeGrant, releaseCodexAuthContextProbeLease } from "../../codex/auth-context";
41
42
  import { consumeComboFailure } from "./core-combo-failure";
42
43
  import { clientCancelledResponse, readDisplaySafeErrorText } from "./core-errors";
@@ -535,6 +536,7 @@ export async function deliverPassthroughResponse(
535
536
  // request. The sibling recorders on this path already decline: the terminal recorder
536
537
  // needs an ok streaming body, and the quota-header snapshot finds no quota headers.
537
538
  recordCodexUpstreamOutcome(config, admissionState.authCtx.accountId, upstreamResponse.status, {
539
+ ...codexFailureWindowOutcomeMeta(admissionState.authCtx),
538
540
  ...quotaMeta,
539
541
  threadId: admissionState.authCtx.affinityKey,
540
542
  fixedAccount: admissionState.authCtx.fixedAccount,
@@ -1,4 +1,4 @@
1
- import { recoverAutoPausedAccount } from "./account-auto-pause-recovery";
1
+ import { recoverAutoPausedAccount, rotatePassthroughKeyOn429 } from "./passthrough-key-rotation";
2
2
  import { adoptCodexWsStage, capturePassthroughAffinity } from "./passthrough-observations";
3
3
  import { spendLedgerStorageErrorResponse } from "./spend-storage-error";
4
4
  import { requestPacingOverloadResponse } from "./pacing-overload";
@@ -53,6 +53,7 @@ import {
53
53
  import { restoreRoutedCustomCalls, RoutedCustomToolCompatError } from "../../responses/custom-tool-compat";
54
54
  import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema";
55
55
  import { MuseToolChoiceCompatibilityError } from "../../adapters/openai-responses/muse-tool-choice";
56
+ import { OpenGatewayInputCompatibilityError } from "../../adapters/openai-responses/opengateway-input";
56
57
  import { formatErrorResponse } from "../../bridge";
57
58
  import { redactSecretString } from "../../lib/redact";
58
59
  import {
@@ -132,7 +133,7 @@ import {
132
133
  import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
133
134
  import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
134
135
  import { captureAccountAutoPauseAttempt, wasAccountAutoPaused, wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
135
- import { recordCodexUpstreamOutcome } from "../../codex/routing";
136
+ import { codexFailureWindowOutcomeMeta, recordCodexUpstreamOutcome } from "../../codex/routing";
136
137
  import { describeUpstreamConnectFailure } from "./upstream-error";
137
138
  import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
138
139
  import {
@@ -172,6 +173,7 @@ import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION }
172
173
  import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit";
173
174
  import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata";
174
175
  import { unboundPoolSpendRefusalResponse } from "../workflow-refusal";
176
+ import { guardPassthroughCompletion, parseOutboundRequestBody } from "./passthrough-completion";
175
177
 
176
178
  /** Transfer an uncommitted Copilot exchange to the existing translated pipeline without new admission. */
177
179
  export interface PassthroughAdapterHandoff { kind: "adapter-handoff" }
@@ -386,6 +388,7 @@ export async function preparePassthroughExchange(
386
388
  || error instanceof XaiToolSchemaCompatibilityError
387
389
  || error instanceof RoutedCustomToolCompatError
388
390
  || error instanceof MuseToolChoiceCompatibilityError
391
+ || error instanceof OpenGatewayInputCompatibilityError
389
392
  ) {
390
393
  return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message));
391
394
  }
@@ -434,16 +437,6 @@ export async function preparePassthroughExchange(
434
437
  // `{model, input, stream}` with no tools and Copilot answers with a `custom_tool_call` for
435
438
  // `apply_patch`. Policing an absent catalog truncates that turn. An unreadable body lands there
436
439
  // too because the proxy cannot establish the caller's declared authorization boundary.
437
- const parseOutboundRequestBody = (bodyText: string): Record<string, unknown> | undefined => {
438
- try {
439
- const body = JSON.parse(bodyText) as unknown;
440
- return body && typeof body === "object" && !Array.isArray(body)
441
- ? body as Record<string, unknown>
442
- : undefined;
443
- } catch {
444
- return undefined;
445
- }
446
- };
447
440
  let outboundRequestBody: Record<string, unknown> | undefined;
448
441
  const declaredWireToolNames = new Set<string>();
449
442
  const declaredBareWireToolNames = new Set<string>();
@@ -990,6 +983,7 @@ export async function preparePassthroughExchange(
990
983
  }
991
984
  if (usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)) {
992
985
  recordCodexUpstreamOutcome(config, admissionState.authCtx.accountId, outcome, {
986
+ ...codexFailureWindowOutcomeMeta(admissionState.authCtx),
993
987
  threadId: admissionState.authCtx.affinityKey,
994
988
  fixedAccount: admissionState.authCtx.fixedAccount,
995
989
  modelId: route.modelId,
@@ -1157,6 +1151,7 @@ export async function preparePassthroughExchange(
1157
1151
  `${route.providerName}|${route.modelId}|${recovery}`,
1158
1152
  { allowFinalRecoveryReserve: transientSendPolicy() === null },
1159
1153
  );
1154
+ if (recovery === "empty-completion") allowance.attempts = Math.min(1, allowance.attempts);
1160
1155
  try {
1161
1156
  return await fetchWithTransientRetry(
1162
1157
  innerRecovery => {
@@ -1188,7 +1183,8 @@ export async function preparePassthroughExchange(
1188
1183
  .then(adoptObservedResponse);
1189
1184
  },
1190
1185
  { abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts,
1191
- onSendsConsumed: transientSendReporter(allowance.permit), claimAmbiguousResend: claimPreHeaderResend },
1186
+ onSendsConsumed: transientSendReporter(allowance.permit),
1187
+ claimAmbiguousResend: recovery === "empty-completion" ? undefined : claimPreHeaderResend },
1192
1188
  );
1193
1189
  } catch (err) {
1194
1190
  return { failed: transportFailureResponse(err) };
@@ -1606,6 +1602,7 @@ export async function preparePassthroughExchange(
1606
1602
  return transportFailureResponse(err);
1607
1603
  }
1608
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; }
1609
1606
  const captureAffinityResponse = (
1610
1607
  response: Response,
1611
1608
  captureAuthCtx: CodexAuthContext = admissionState.authCtx,
@@ -1942,6 +1939,9 @@ export async function preparePassthroughExchange(
1942
1939
  break;
1943
1940
  }
1944
1941
 
1942
+ upstreamResponse = await guardPassthroughCompletion(
1943
+ upstreamResponse, request, config, upstream.signal, logCtx, rebuildAndRefetch,
1944
+ );
1945
1945
  // Where this relay dials upstream. Local infrastructure (loopback / private / `.local` / `.lan`)
1946
1946
  // is operator-trusted and its silent phases are normal, so an unset stall budget resolves to
1947
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
+ }
@@ -13,6 +13,7 @@ import { resolvePolicyProfileId } from "../../routing/profile";
13
13
  import { parseSyntheticRowId } from "../fast-row";
14
14
  import { routeConcreteModel } from "../../router";
15
15
  import { isPolicyCandidateRefusal, policyWireDestination, type PolicyRequestScope } from "./policy-request-scope";
16
+ import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy";
16
17
 
17
18
  type CoreHandler = typeof handleResponsesCore;
18
19
  type CoreOptions = Parameters<CoreHandler>[3];
@@ -25,6 +26,23 @@ function candidateKey(candidate: Pick<RouteCandidateTrace, "provider" | "model">
25
26
  return `${candidate.provider}\u0000${candidate.model}`;
26
27
  }
27
28
 
29
+ /**
30
+ * OCX-11: the destination a candidate physically dispatches to, after provider aliases and
31
+ * blockedModelRedirects, so the fallback guard compares where requests really go. A candidate
32
+ * that does not resolve keeps its own selector.
33
+ */
34
+ function resolvedPolicyTarget(
35
+ config: OcxConfig,
36
+ candidate: Pick<RouteCandidateTrace, "provider" | "model">,
37
+ ): { provider: string; model: string } {
38
+ try {
39
+ const routed = routeConcreteModel(config, `${candidate.provider}/${candidate.model}`);
40
+ return { provider: routed.providerName, model: routed.modelId };
41
+ } catch {
42
+ return { provider: candidate.provider, model: candidate.model };
43
+ }
44
+ }
45
+
28
46
  function staysWithinPolicyAfterRedirect(
29
47
  config: OcxConfig,
30
48
  policyEligibility: ReadonlySet<string>,
@@ -219,12 +237,32 @@ export async function handleResponsesWithPolicyFallback(
219
237
  policyScope.triedDestinations.add(policyScope.preparedDestination ?? policyWireDestination({
220
238
  providerName: initialTrace.selected.provider, modelId: initialTrace.selected.model,
221
239
  }));
240
+ let current = resolvedPolicyTarget(config, initialTrace.selected);
241
+ const policyRoute = `policy/${initialTrace.profile!.id}`;
222
242
 
223
243
  while (!storedPool401ReplayDispatched && await shouldHopPolicyCandidate(response, req.signal)) {
224
244
  if (req.signal.aborted) return response;
225
245
  const next = rankPolicyFallbackCandidates(initialTrace, tried)
226
246
  .find(candidate => staysWithinPolicyAfterRedirect(config, policyEligibility, candidate, policyScope.triedDestinations));
227
247
  if (!next) return response;
248
+ // OCX-11: the next candidate is a hop; the fallback policy decides before it is sent, on the
249
+ // redirected destination the guard above already validated.
250
+ const nextTarget = resolvedPolicyTarget(config, next);
251
+ const hop = guardFallbackHop(config, {
252
+ site: "routing-profile",
253
+ route: policyRoute,
254
+ aliases: [initialRequestedModel ?? initialTrace.requestedModel],
255
+ from: current,
256
+ to: nextTarget,
257
+ trigger: fallbackTriggerForStatus(response.status),
258
+ admission: options.admission,
259
+ failureStatus: response.status,
260
+ retryAfter: response.headers.get("retry-after"),
261
+ });
262
+ if (!hop.ok) {
263
+ try { await response.body?.cancel(); } catch { /* the refusal replaces this body */ }
264
+ return hop.response;
265
+ }
228
266
  tried.add(candidateKey(next));
229
267
 
230
268
  // Retain the failed response's log owner until a candidate actually replaces it. This is
@@ -243,7 +281,12 @@ export async function handleResponsesWithPolicyFallback(
243
281
  if (!Object.hasOwn(failureLog, key)) Reflect.deleteProperty(logCtx, key);
244
282
  }
245
283
  Object.assign(logCtx, failureLog);
246
- } else response = nextResponse;
284
+ } else {
285
+ // Dispatched: only a candidate that was not locally skipped counts as a taken hop.
286
+ hop.commit();
287
+ response = nextResponse;
288
+ current = nextTarget;
289
+ }
247
290
  if (policyScope.preparedDestination) policyScope.triedDestinations.add(policyScope.preparedDestination);
248
291
  } catch (error) {
249
292
  const overload = requestPacingOverloadResponse(error);
@@ -0,0 +1,21 @@
1
+ import { formatErrorResponse } from "../../bridge";
2
+ import { ReasoningArchiveBusyError, ReasoningArchiveError,
3
+ ReasoningArchiveQuotaError } from "../../responses/reasoning-retention";
4
+
5
+ /** Local storage refusal before any send. Never expose filesystem messages or causes. */
6
+ export function reasoningArchiveErrorResponse(error: unknown): Response | undefined {
7
+ let code: string;
8
+ let message: string;
9
+ if (error instanceof ReasoningArchiveQuotaError) {
10
+ code = "reasoning_archive_quota_exceeded";
11
+ message = "Local reasoning archive is full. Review and manually remove older reasoning archives, then retry the unchanged request.";
12
+ } else if (error instanceof ReasoningArchiveBusyError) {
13
+ code = "reasoning_archive_busy";
14
+ message = "Another local reasoning archive writer is active. Wait for it to finish, then retry the unchanged request.";
15
+ } else if (error instanceof ReasoningArchiveError) {
16
+ code = "reasoning_archive_unavailable";
17
+ message = "Local reasoning archive is unavailable. Check archive directory permissions and local storage health before retrying the unchanged request.";
18
+ } else return undefined;
19
+ // Match the local spend-ledger storage refusal's 503 convention without guessing a delay.
20
+ return formatErrorResponse(503, "server_error", message, { code, classified: true });
21
+ }
@@ -100,6 +100,7 @@ import type {
100
100
  import {
101
101
  codexRouteCredentialDomainHeaders,
102
102
  codexRouteCredentialOwnership,
103
+ mainHardLockFallbackCallerCheck,
103
104
  resolveResponsesCodexAuth,
104
105
  withClaudeNativeSession,
105
106
  } from "./core-auth";
@@ -159,6 +160,7 @@ import {
159
160
  import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health";
160
161
  import { applyCompactionRoutingOverride, compactionRoutingKeepsProviderIdentity } from "./compaction-routing";
161
162
  import { applyMainHardLockFallbackOverride } from "./main-hard-lock-routing";
163
+ import { FallbackRefusedError } from "../../routing/fallback-policy";
162
164
  import { codexAuthContextLogLabel } from "../../codex/account-label";
163
165
  import {
164
166
  conversationStateBindingFromAuth,
@@ -364,7 +366,17 @@ export async function prepareResponsesRequest(
364
366
  // must run before comboIdFromRawBody reads `model`.
365
367
  if (!concreteSelection && !options.compactionRoutingOverride && !isCompactionTrigger && !options.internalDecisionCall
366
368
  && !shadowCallIntercepted && !shadowCallMatches && !memoryModelApplies && !threadSpawn && inboundWire === "responses") {
367
- options.mainHardLockFallbackOverride = applyMainHardLockFallbackOverride(body, config);
369
+ try {
370
+ options.mainHardLockFallbackOverride = await applyMainHardLockFallbackOverride(
371
+ body, config, undefined, mainHardLockFallbackCallerCheck(req, config, options), options.admission,
372
+ );
373
+ } catch (error) {
374
+ if (options.abortSignal?.aborted || req.signal.aborted) return clientCancelledResponse();
375
+ if (error instanceof FallbackRefusedError) return error.response;
376
+ const mapped = mapCodexAuthContextErrorToResponse(error, { now: Date.now() });
377
+ if (mapped) return mapped;
378
+ throw error;
379
+ }
368
380
  }
369
381
  const comboId = !concreteSelection ? comboIdFromRawBody(body, config) : null;
370
382
  // A decision model may be a plain model or a non-JEV combo. A JEV combo here would recurse
@@ -995,7 +1007,9 @@ export async function prepareResponsesRequest(
995
1007
  subagentFallbackModelEligibleAccountIdsForModel,
996
1008
  fallbackChain,
997
1009
  candidateRoute => canPassThroughEncryptedV2AgentTask(candidateRoute, inboundWire),
1010
+ options.admission,
998
1011
  );
1012
+ if (fallback?.refusal) return fallback.refusal;
999
1013
  if (fallback) {
1000
1014
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
1001
1015
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
@@ -1209,11 +1223,15 @@ export async function prepareResponsesRequest(
1209
1223
  recoverySelectionOptions,
1210
1224
  subagentFallbackAccountPreview,
1211
1225
  subagentFallbackModelEligibleAccountIdsForModel,
1226
+ undefined,
1227
+ undefined,
1228
+ options.admission,
1212
1229
  );
1213
1230
  } finally {
1214
1231
  recoverySelectionAdmission?.release();
1215
1232
  }
1216
1233
  })();
1234
+ if (fallback?.refusal) return fallback.refusal;
1217
1235
  if (fallback) {
1218
1236
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
1219
1237
  (logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;