@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
@@ -1,6 +1,7 @@
1
1
  import { effectiveAzureOutputBudget } from "../../adapters/azure";
2
2
  import { resolveWireProtocolOverride } from "../adapter-resolve";
3
- import type { CodexAccountModelRefusal } from "../../combos/failover";
3
+ import { comboCooldownRetryAfterSeconds, type CodexAccountModelRefusal } from "../../combos/failover";
4
+ import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy";
4
5
  import {
5
6
  isCodexReasoningEffort,
6
7
  isDeclaredReasoningEffort,
@@ -32,6 +33,7 @@ import {
32
33
  isComboTargetInCooldown,
33
34
  snapshotComboQuotaCooldowns,
34
35
  comboExclusionReasons,
36
+ comboTargetSkipTrigger,
35
37
  noteComboSuccess,
36
38
  comboFailureDecision,
37
39
  advanceComboAfterFailure,
@@ -124,6 +126,20 @@ import { clientWireOf } from "../inference/client-wire";
124
126
  */
125
127
  export const COMBO_TARGET_BASE_SENDS = CODEX_TEXT_GUARDED_BUDGET_POLICY.baseSendAllowance;
126
128
 
129
+ /**
130
+ * OCX-11: where a combo target physically dispatches, after provider aliases and
131
+ * blockedModelRedirects. A target that does not resolve keeps its configured selector; its
132
+ * dispatch fails on its own.
133
+ */
134
+ function resolvedComboDestination(config: OcxConfig, target: { provider: string; model: string }): { provider: string; model: string } {
135
+ try {
136
+ const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
137
+ return { provider: route.providerName, model: route.modelId };
138
+ } catch {
139
+ return { provider: target.provider, model: target.model };
140
+ }
141
+ }
142
+
127
143
  function cooledPoolAccountLabel(config: OcxConfig, providerName: string, modelId: string, label: string | undefined): string | undefined {
128
144
  if (!label) return undefined;
129
145
  const instance = configuredAnthropicInstance(config, providerName);
@@ -638,6 +654,30 @@ export async function executeComboResponses(
638
654
  ? clientCancelledResponse()
639
655
  : comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible));
640
656
  }
657
+ // OCX-11: an ordered failover combo that starts on a later target than its first one, for any
658
+ // reason (cooling down, provider disabled, cached quota spent, ineligible for this request),
659
+ // makes a proactive hop, so the fallback policy decides before anything is sent. Both ends are
660
+ // the resolved destinations, after provider aliases and blockedModelRedirects.
661
+ const primaryTarget = combo.targets[0];
662
+ // An allowed hop is recorded only once its dispatch is admitted below.
663
+ let pendingHopCommit: (() => void) | undefined;
664
+ if (!toolLoopPin && (combo.strategy ?? "failover") === "failover" && primaryTarget
665
+ && targetKey(primaryTarget) !== targetKey(pick.target)) {
666
+ const primaryCooling = isComboTargetInCooldown(comboId, primaryTarget, initialNow);
667
+ const proactive = guardFallbackHop(config, {
668
+ site: "combo",
669
+ route: `combo/${comboId}`,
670
+ aliases: [requestedModel],
671
+ from: resolvedComboDestination(config, primaryTarget),
672
+ to: resolvedComboDestination(config, pick.target),
673
+ trigger: primaryCooling ? "cooldown" : comboTargetSkipTrigger(config, primaryTarget, initialNow),
674
+ admission: options.admission,
675
+ retryAfter: primaryCooling ? comboCooldownRetryAfterSeconds(comboId, initialNow) ?? null : null,
676
+ now: initialNow,
677
+ });
678
+ if (!proactive.ok) return proactive.response;
679
+ pendingHopCommit = proactive.commit;
680
+ }
641
681
  let jevDecision: JevDecision | undefined;
642
682
  if (combo.strategy === "jev" && !toolLoopPin) {
643
683
  const choices = eligibleJevComboChoices(config, comboId, targetEligible, Date.now());
@@ -760,6 +800,9 @@ export async function executeComboResponses(
760
800
  // no-more-targets branch; a budget refusal ends the ladder one iteration later, where that
761
801
  // iteration's own `childLog` is already out of scope.
762
802
  let lastFailedChildLog: RequestLogContext | undefined;
803
+ // The target behind `lastFailure`, so the next dispatch can tell a hop from a same-target retry.
804
+ let lastFailedTarget: { provider: string; model: string } | undefined;
805
+ let lastFailedRetryAfter: string | undefined;
763
806
  // The exhausted-combo mapping below runs outside the loop, where `failure.upstreamCode`
764
807
  // is gone, so carry the loop's own classification decision instead of re-deriving a
765
808
  // weaker one from the status alone (#4149).
@@ -812,7 +855,29 @@ export async function executeComboResponses(
812
855
  while (pick) {
813
856
  if (options.abortSignal?.aborted) return clientCancelledResponse();
814
857
  const firstComboTarget = comboTargetsDispatched === 0;
858
+ // The physical destination after provider aliases and blockedModelRedirects: hops are
859
+ // classified by where the request really goes, not by the selector the combo names.
815
860
  const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
861
+ // OCX-11: every later dispatch after a failure is a hop when the target changed. One check
862
+ // here covers the ordinary failover pick and the recovery picks alike.
863
+ if (!firstComboTarget && lastFailure && lastFailedTarget) {
864
+ const hop = guardFallbackHop(config, {
865
+ site: "combo",
866
+ route: `combo/${comboId}`,
867
+ aliases: [requestedModel],
868
+ from: lastFailedTarget,
869
+ to: { provider: targetRoute.providerName, model: targetRoute.modelId },
870
+ trigger: fallbackTriggerForStatus(lastFailure.status),
871
+ admission: options.admission,
872
+ failureStatus: lastFailure.status,
873
+ retryAfter: lastFailedRetryAfter ?? null,
874
+ });
875
+ if (!hop.ok) {
876
+ if (lastFailedChildLog) adoptFailedChildLog(lastFailedChildLog);
877
+ return hop.response;
878
+ }
879
+ pendingHopCommit = hop.commit;
880
+ }
816
881
  // The inherited spend tracker observes this parent log, before the child has its own label.
817
882
  logCtx.spendPoolId = targetRoute.providerName;
818
883
  // Combo prepays before its child normalizes the route; the parent tracker must
@@ -851,6 +916,9 @@ export async function executeComboResponses(
851
916
  // an earlier one already recorded one.
852
917
  return exhaustedFailure();
853
918
  }
919
+ // Admitted: only now is a guarded hop to this target recorded as taken.
920
+ pendingHopCommit?.();
921
+ pendingHopCommit = undefined;
854
922
  const initialSend = hopDecision?.allowed ? { permit: hopDecision.permit, producerOwned: false } : undefined;
855
923
  try {
856
924
  comboTargetsDispatched += 1;
@@ -1147,6 +1215,9 @@ export async function executeComboResponses(
1147
1215
  quotaFailedChildLog = childLog;
1148
1216
  }
1149
1217
  lastFailedChildLog = childLog;
1218
+ // The failed child's resolved destination, so the next hop compares physical targets.
1219
+ lastFailedTarget = { provider: targetRoute.providerName, model: targetRoute.modelId };
1220
+ lastFailedRetryAfter = failure.retryAfter;
1150
1221
  // A replacement that answers 200 is unmarked, and its zero-output failure only exists once
1151
1222
  // preflight has rebuilt the stream as a fresh Response. A spent grant never hops: a status the
1152
1223
  // client would resend becomes the refusal, and anything else reaches the client as it is.
@@ -1165,6 +1236,9 @@ export async function executeComboResponses(
1165
1236
  : comboFailureDecision(failure.response.status, failure.classificationText, {
1166
1237
  code: failure.upstreamCode,
1167
1238
  codexModelRefusal: failure.codexModelRefusal,
1239
+ // The adapter the failed leg actually resolved: a configured `kiro` provider can route a
1240
+ // model through another wire via `modelAdapters`, and an unresolved leg grants no hop.
1241
+ failedAdapter: childLog.providerAdapter,
1168
1242
  });
1169
1243
  const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true;
1170
1244
  // Local byte admission has its own diagnostic; do not relabel it as an upstream refusal.
@@ -259,8 +259,15 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
259
259
  }
260
260
  }
261
261
 
262
- // The flat stream-error envelope carries type/message at the top level rather than under
263
- // an error object; the same anchored identity applies there.
262
+ // Flat SSE error events carry their code/message beside type:error, without a nested
263
+ // error object. Match only the same dedicated code or exact verification identity.
264
+ if ((record.error === undefined || record.error === null)
265
+ && (record.type === "error" || record.type === "invalid_request_error")
266
+ && (record.code === "invalid_encrypted_content" || ((record.code === null || record.code === undefined)
267
+ && typeof record.message === "string" && record.message.startsWith("The encrypted content ")
268
+ && record.message.endsWith(" could not be verified. Reason: Encrypted content could not be decrypted or parsed.")))) return true;
269
+
270
+ // The caller-mismatch identity also arrives as a flat typed error.
264
271
  if (
265
272
  record.type === "invalid_request_error"
266
273
  && typeof record.message === "string"
@@ -277,14 +284,14 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
277
284
 
278
285
 
279
286
  /**
280
- * Opted-in native reasoning retention forwards ciphertext another model or account minted. ChatGPT
281
- * can refuse it inside a 200 stream -- a Codex WebSocket `error` frame or `response.failed` --
282
- * rather than as an HTTP 4xx, so the passthrough inspects the zero-output prelude of exactly these
283
- * requests. Ordinary turns keep the function-output-only inspection.
287
+ * Opaque state can be refused inside a 200 stream rather than as an HTTP 4xx. The passthrough
288
+ * inspects its bounded zero-output prelude even when a restored thread has no serving record;
289
+ * successful native ciphertext remains unchanged and blobless turns do not gain inspection.
284
290
  */
285
291
  export function retainedReasoningStreamCandidate(parsed: OcxParsedRequest, outboundBody: string | undefined): boolean {
286
- return parsed._nativeReasoningRetainedAcrossRoute === true
287
- && parsed._stripReasoningEncryptedContent !== true
292
+ // A restored thread has no process-local serving record. Inspect its actual outbound
293
+ // opaque state too; only an explicit zero-output rejection can authorize the rebuild.
294
+ return parsed._stripReasoningEncryptedContent !== true
288
295
  && outboundResponsesBodyCarriesOpaqueBlob(outboundBody);
289
296
  }
290
297
 
@@ -310,7 +317,7 @@ export function retainedReasoningStreamRejection(
310
317
 
311
318
 
312
319
  /**
313
- * Whether an upstream Responses 4xx authoritatively rejected opaque replay state.
320
+ * Whether an upstream Responses 4xx or explicitly identified 502 rejected opaque replay state.
314
321
  *
315
322
  * The outbound-body check is intentional: the inbound transcript may contain a proxy envelope or
316
323
  * compaction blob that the adapter already lowered, in which case a replay would be byte-identical.
@@ -329,8 +336,10 @@ export function shouldAttemptOpaqueBlobRecovery(args: {
329
336
  const acceptedStatus = (args.status >= 400 && args.status < 500)
330
337
  || (
331
338
  args.status === 502
332
- && outboundResponsesBodyCarriesEncryptedFunctionOutput(args.outboundBody)
333
- && isEncryptedFunctionOutputRejection(args.errorBody)
339
+ // Gateways can project a reasoning-only rejection as 502. The existing strict
340
+ // identity classifier below still excludes generic gateway/transport failures.
341
+ && (!isEncryptedFunctionOutputRejection(args.errorBody)
342
+ || outboundResponsesBodyCarriesEncryptedFunctionOutput(args.outboundBody))
334
343
  );
335
344
  return acceptedStatus
336
345
  && adapterSpeaksResponsesWire(args.adapterName)
@@ -0,0 +1,121 @@
1
+ import type { TranslatorBudget } from "../../lib/translator-budget";
2
+ import type { AdapterRequest } from "../../adapters/base";
3
+ import { readBoundedResponseBody } from "../../lib/bounded-body";
4
+
5
+ // DeepSeek's image-token accounting does not include the inline base64 wire cost.
6
+ // https://api-docs.deepseek.com/guides/vision/ documents a separate 48 MiB body cap.
7
+ const INLINE_THRESHOLD = 40 * 1024 * 1024;
8
+ const TARGET_BYTES = 32 * 1024 * 1024;
9
+ const MAX_FILE_BYTES = 64 * 1024 * 1024;
10
+ const MAX_REPAIR_BYTES = 128 * 1024 * 1024;
11
+ const MAX_CACHE_ENTRIES = 512;
12
+ const CACHE_MS = 50 * 60 * 1000;
13
+ let activeRepairs = 0;
14
+ const files = new Map<string, { id: string; until: number }>();
15
+
16
+ /** Narrow parsed JSON objects without treating arrays as image or message records. */
17
+ function record(value: unknown): value is Record<string, unknown> {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value);
19
+ }
20
+
21
+ /** Only the first-party Responses image contract, never a similarly named gateway. */
22
+ export function needsDeepSeekImageFiles(request: AdapterRequest): boolean {
23
+ const url = new URL(request.url);
24
+ return url.origin === "https://api.deepseek.com"
25
+ && (url.pathname === "/responses" || url.pathname === "/v1/responses")
26
+ && request.method === "POST"
27
+ && Buffer.byteLength(request.body, "utf8") > INLINE_THRESHOLD;
28
+ }
29
+
30
+ /**
31
+ * Replace older inline images with same-account, expiring file references. The client history
32
+ * and images stay intact; provider token usage and Codex's compaction policy remain truthful.
33
+ * The caller owns the provider's transport, cancellation and credential-selection fence.
34
+ */
35
+ export async function deepSeekImageFileBody(
36
+ request: AdapterRequest,
37
+ execute: typeof globalThis.fetch,
38
+ abortSignal?: AbortSignal,
39
+ options?: { allowedModels: readonly string[]; budget?: TranslatorBudget },
40
+ ): Promise<string | undefined> {
41
+ if (!options?.allowedModels.length || !needsDeepSeekImageFiles(request)) return undefined;
42
+ const requestBytes = Buffer.byteLength(request.body, "utf8");
43
+ if (requestBytes > MAX_REPAIR_BYTES) throw new Error("DeepSeek image replay exceeds the 128 MiB repair limit; compact the request.");
44
+ if (activeRepairs >= 2) throw new Error("DeepSeek image replay preparation is busy; retry after an active request completes.");
45
+ activeRepairs++;
46
+ const releaseCopy = options.budget?.observeAcceptedRequestCopy(requestBytes * 2);
47
+ try {
48
+ abortSignal?.throwIfAborted();
49
+ const body: unknown = JSON.parse(request.body);
50
+ if (!record(body) || !options.allowedModels.includes(String(body.model))
51
+ || !Array.isArray(body.input)) return undefined;
52
+ const auth = new Headers(request.headers).get("authorization");
53
+ if (!auth) return undefined;
54
+ const scope = new Bun.CryptoHasher("sha256").update(auth).digest("hex");
55
+ const signal = AbortSignal.any([...(abortSignal ? [abortSignal] : []), AbortSignal.timeout(60_000)]);
56
+ let bytes = Buffer.byteLength(request.body, "utf8");
57
+ let changed = false;
58
+ for (const item of body.input) {
59
+ if (!record(item)) continue;
60
+ const parts = item.type === "function_call_output" || item.type === "custom_tool_call_output"
61
+ ? item.output : item.role === "user" || item.role === "developer" ? item.content : undefined; // DeepSeek rejects other roles' images.
62
+ if (!Array.isArray(parts)) continue;
63
+ for (const part of parts) {
64
+ if (bytes <= TARGET_BYTES) break;
65
+ // file_id ignores detail; keep explicit low-resolution requests inline.
66
+ if (!record(part) || part.type !== "input_image" || part.file_id !== undefined
67
+ || part.detail === "low" || typeof part.image_url !== "string") continue;
68
+ const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,/.exec(part.image_url);
69
+ if (!match) continue;
70
+ signal.throwIfAborted();
71
+ const data = part.image_url.slice(match[0].length);
72
+ if (data.length > Math.ceil(MAX_FILE_BYTES / 3) * 4) continue;
73
+ const hash = new Bun.CryptoHasher("sha256").update(part.image_url).digest("hex");
74
+ const key = `${scope}:${hash}`;
75
+ let entry = files.get(key);
76
+ if (entry && entry.until <= Date.now()) { files.delete(key); entry = undefined; }
77
+ if (!entry) {
78
+ // Conservative peak: UTF-16 base64 slice/re-encoding, decoded buffer, Blob and multipart.
79
+ const releaseUpload = options.budget?.observeExternallyCapped("provider_image_upload", data.length * 4 + Math.ceil(data.length * 3 / 4) * 3 + 65_536);
80
+ try {
81
+ const image = Buffer.from(data, "base64");
82
+ if (image.length > MAX_FILE_BYTES || image.toString("base64") !== data) continue;
83
+ const form = new FormData();
84
+ form.set("purpose", "user_data");
85
+ form.set("expires_after[anchor]", "created_at");
86
+ form.set("expires_after[seconds]", "3600");
87
+ form.set("file", new Blob([image], { type: match[1] }), "image");
88
+ // Never follow a redirect with credentials or copy request-specific Content-Length.
89
+ const response = await execute("https://api.deepseek.com/files", {
90
+ method: "POST", headers: { authorization: auth }, body: form, signal, redirect: "manual",
91
+ });
92
+ if (!response.ok) {
93
+ await response.body?.cancel();
94
+ throw new Error(`DeepSeek image upload failed (HTTP ${response.status}); conversation images were preserved.`);
95
+ }
96
+ const result = await readBoundedResponseBody(response, { signal, maxBytes: 65_536 });
97
+ let uploaded: unknown;
98
+ try { uploaded = JSON.parse(result.text); } catch { /* Safe, fixed error below. */ }
99
+ if (result.truncated || !record(uploaded) || typeof uploaded.id !== "string"
100
+ || !/^file-api-[A-Za-z0-9_-]{1,200}$/.test(uploaded.id)) {
101
+ throw new Error("DeepSeek image upload returned an invalid file reference; conversation images were preserved.");
102
+ }
103
+ // Cache only opaque ids and hashes, not image bytes or plaintext credentials.
104
+ entry = { id: uploaded.id, until: Date.now() + CACHE_MS };
105
+ if (typeof uploaded.expires_at === "number") entry.until = Math.min(entry.until, uploaded.expires_at * 1000 - 60_000);
106
+ while (files.size >= MAX_CACHE_ENTRIES) files.delete(files.keys().next().value!);
107
+ files.set(key, entry);
108
+ } finally { releaseUpload?.(); }
109
+ }
110
+ const removedBytes = part.image_url.length + "image_url".length;
111
+ delete part.image_url;
112
+ part.file_id = entry.id;
113
+ bytes += entry.id.length + "file_id".length - removedBytes;
114
+ changed = true;
115
+ }
116
+ }
117
+ if (!changed) return undefined;
118
+ const releaseSerialization = options.budget?.observeExternallyCapped("passthrough_serialization", requestBytes * 2);
119
+ try { return JSON.stringify(body); } finally { releaseSerialization?.(); }
120
+ } finally { releaseCopy?.(); activeRepairs--; }
121
+ }
@@ -243,6 +243,11 @@ export async function* guardEmptyCompletionEventStream(
243
243
  heldBytes = 0;
244
244
  return released;
245
245
  };
246
+ // Held events from a discarded attempt are released with the retry's output, so a boundary
247
+ // keeps the retry's reasoning from being appended to the discarded attempt's reasoning item.
248
+ const markRetryBoundary = () => {
249
+ if (held.length > 0 && held.at(-1)?.type !== "assistant_boundary") held.push({ type: "assistant_boundary" });
250
+ };
246
251
 
247
252
  while (true) {
248
253
  let terminalSeen = false;
@@ -279,6 +284,7 @@ export async function* guardEmptyCompletionEventStream(
279
284
  if (retries < maxRetries) {
280
285
  // Suppress the terminal: the client must never see a completed event
281
286
  // for a turn that produced nothing. Retry the identical turn.
287
+ markRetryBoundary();
282
288
  retries += 1;
283
289
  try {
284
290
  source = await options.continuation();
@@ -300,6 +306,7 @@ export async function* guardEmptyCompletionEventStream(
300
306
  // malformed function call). Nothing actionable reached the client, so this is the same
301
307
  // replay-safe shape as an empty completion: retry the identical turn once.
302
308
  usage = mergeUsage(usage, event.usage);
309
+ markRetryBoundary();
303
310
  retries += 1;
304
311
  try {
305
312
  source = await options.continuation();
@@ -351,6 +358,7 @@ export async function* guardEmptyCompletionEventStream(
351
358
  // actionable reached the client. Retry once, then surface a stated error
352
359
  // instead of letting the bridge reduce the turn to adapter_eof.
353
360
  if (!sawContent && !passthrough && retries < maxRetries) {
361
+ markRetryBoundary();
354
362
  retries += 1;
355
363
  try {
356
364
  source = await options.continuation();
@@ -265,13 +265,13 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
265
265
  // codex-rs appends one agent_message (any of the four codex-rs message types) at the
266
266
  // current input tail. Historical
267
267
  // agent messages may be adjacent in full-history bodies; they must not poison the
268
- // later task. compaction_trigger/additional_tools are trailing metadata rather than
269
- // a newer user turn.
268
+ // later task. compaction_trigger/additional_tools/configuration_update are trailing
269
+ // metadata rather than a newer user turn; Codex may record an effort update after the task.
270
270
  let index = input.length - 1;
271
271
  while (index >= 0) {
272
272
  const item = input[index];
273
273
  const type = item && typeof item === "object" ? (item as { type?: unknown }).type : undefined;
274
- if (type !== "compaction_trigger" && type !== "additional_tools") break;
274
+ if (type !== "compaction_trigger" && type !== "additional_tools" && type !== "configuration_update") break;
275
275
  index -= 1;
276
276
  }
277
277
  const item = input[index];
@@ -1,4 +1,5 @@
1
1
  import type { NativeResponseControl } from "./native-response-control";
2
+ import { PROVIDER_HEADER_DEADLINE as HEADER_DEADLINE, type ProviderHeaderDeadline as HeaderDeadline } from "../../lib/provider-admission-wait";
2
3
  import type { CodexWsPreOutputRefusalHold } from "./codex-ws-wire";
3
4
  import type { Server } from "bun";
4
5
  import {
@@ -62,9 +63,6 @@ export function physicalSendObserverInit(observer: AccountAutoPauseObserver | un
62
63
  }
63
64
  // One WS attempt and its HTTP fallback are one logical physical-send receipt.
64
65
  const WS_FALLBACK_RECEIPT = Symbol.for("opencodex.ws-fallback-receipt");
65
- // Replacement admission waits on the client signal, outside the response-header deadline.
66
- const HEADER_DEADLINE = Symbol("header-deadline");
67
- type HeaderDeadline = { clientSignal: AbortSignal; pause(): void; resume(): void };
68
66
 
69
67
  /**
70
68
  * Announce once, per provider, that an explicit egress route moved this provider off the
@@ -248,6 +246,20 @@ export function sendWithConnectionPolicy(
248
246
  });
249
247
  }
250
248
 
249
+ interface CodexWsExecutor { provider: OcxProviderConfig; http: typeof globalThis.fetch }
250
+ const codexWsExecutors = new WeakMap<object, CodexWsExecutor>();
251
+ const codexWsExecutor = (send: (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => Promise<Response>, binding: CodexWsExecutor) => {
252
+ codexWsExecutors.set(send, binding);
253
+ return send as typeof globalThis.fetch;
254
+ };
255
+ /**
256
+ * Set for the executor a `dispatchOverride` receives on the WS branch: it must not get HTTP-only
257
+ * send policy. `provider` is the one WS eligibility was checked against; a dispatch-time
258
+ * replacement was not, so it sends through `http` (the same fallback) instead.
259
+ */
260
+ export const codexWsExecutorBinding = (execute: unknown): CodexWsExecutor | undefined =>
261
+ typeof execute === "function" ? codexWsExecutors.get(execute) : undefined;
262
+
251
263
  export interface ProviderFetchOptions {
252
264
  accountAutoPause?: (name: string, provider: OcxProviderConfig, headers: Headers) => AccountAutoPauseObserver | undefined;
253
265
  /**
@@ -330,7 +342,9 @@ export function providerFetch(
330
342
  ) as typeof globalThis.fetch);
331
343
  const httpFetch = Object.assign(
332
344
  async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
333
- reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>) => {
345
+ reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>,
346
+ // False only for the HTTP fallback of a WS send the override already wrapped: one logical send.
347
+ override: ProviderFetchOptions["dispatchOverride"] | null = options.dispatchOverride) => {
334
348
  // Refuse before any dispatch side effect where that is sound. `beforeDispatch` commits
335
349
  // attempt accounting and consumes admission state, so a refusal firing after it would
336
350
  // charge an attempt for a send that never happens, and a throwing hook would mask the
@@ -340,7 +354,7 @@ export function providerFetch(
340
354
  // full decision can be made now. With an override, only the configured value is checked:
341
355
  // the override may rebuild against a different host and select a different transport, and
342
356
  // refusing on this destination would reject a request whose real route is fine.
343
- if (options.dispatchOverride) egressFor(input);
357
+ if (override) egressFor(input);
344
358
  else providerEgressSendInit(egressBinding, transport, input);
345
359
  // The hook inspects the outgoing headers and refuses the send by throwing; it is not a
346
360
  // mutator, and the copy it receives is deliberately not threaded onward. `Connection`
@@ -357,8 +371,8 @@ export function providerFetch(
357
371
  else if (!notified) { options.onPhysicalDispatch!(); notified = true; }
358
372
  } } : {}),
359
373
  };
360
- return options.dispatchOverride
361
- ? options.dispatchOverride(input, dispatchInit, dispatch, reacquirePacing)
374
+ return override
375
+ ? override(input, dispatchInit, dispatch, reacquirePacing)
362
376
  : dispatch(input, dispatchInit);
363
377
  },
364
378
  { preconnect },
@@ -397,9 +411,15 @@ export function providerFetch(
397
411
  options.onPhysicalDispatch?.();
398
412
  };
399
413
  const fallback = ((url: Parameters<typeof globalThis.fetch>[0], fallbackInit?: RequestInit) =>
400
- httpFetch(url, { ...fallbackInit, [WS_FALLBACK_RECEIPT]: notifyOnce } as RequestInit, reacquirePacing)) as typeof globalThis.fetch;
401
- return codexWsUpstreamFetch(input, init, fallback, runtime, options.onCodexWsQuota, options.beforeDispatch, options.nativeControl,
402
- async () => { (await waitForPacing(init.signal ?? undefined))?.release(); }, notifyOnce, options.preOutputRefusalHold);
414
+ httpFetch(url, { ...fallbackInit, [WS_FALLBACK_RECEIPT]: notifyOnce } as RequestInit, reacquirePacing, null)) as typeof globalThis.fetch;
415
+ const wsSend = codexWsExecutor((url: Parameters<typeof globalThis.fetch>[0], wsInit?: RequestInit) => codexWsUpstreamFetch(String(url), wsInit ?? init,
416
+ fallback, runtime, options.onCodexWsQuota, options.beforeDispatch, options.nativeControl,
417
+ async () => { (await waitForPacing(init.signal ?? undefined))?.release(); }, notifyOnce, options.preOutputRefusalHold),
418
+ { provider, http: markEgressTransparentExecutor(fallback) });
419
+ // A WS frame is a physical send like any other, so it runs the same override: that is where a
420
+ // key-auth send is committed to its attempt row and the send budget is rebound to the
421
+ // credential that left. Without it a key-auth WS turn logged `sendCount: 0`.
422
+ return options.dispatchOverride ? options.dispatchOverride(input, init, wsSend, reacquirePacing) : wsSend(input, init);
403
423
  }
404
424
  return httpFetch(input, init, reacquirePacing);
405
425
  };
@@ -55,6 +55,9 @@ const REMOTE_IMAGE_TOKENS = 850;
55
55
  /** Decoded image bytes per token. Coarse tile-count proxy, not a provider formula. */
56
56
  const IMAGE_BYTES_PER_TOKEN = 750;
57
57
 
58
+ /** Compressed-audio bytes per token. More conservative than the image proxy. */
59
+ const AUDIO_BYTES_PER_TOKEN = 256;
60
+
58
61
  export interface InputAdmissionResult {
59
62
  admitted: boolean;
60
63
  estimatedTokens: number;
@@ -85,6 +88,7 @@ function imageTokens(imageUrl: string): number {
85
88
  function contentPartTokens(part: OcxContentPart, modelId: string): number {
86
89
  if (part.type === "image") return imageTokens(part.imageUrl);
87
90
  if (part.type === "video") return imageTokens(part.videoUrl);
91
+ if (part.type === "audio") return base64PayloadTokens(part.data, AUDIO_BYTES_PER_TOKEN);
88
92
  // An inline document is a base64 payload, not a sentence: estimating it from its marker would
89
93
  // admit a request whose real input is orders of magnitude larger. Counted arithmetically —
90
94
  // rebuilding the data URL here would materialize a second request-sized string just to measure it.
@@ -92,10 +96,10 @@ function contentPartTokens(part: OcxContentPart, modelId: string): number {
92
96
  return estimateTokens(part.text, modelId);
93
97
  }
94
98
 
95
- function base64PayloadTokens(base64: string): number {
99
+ function base64PayloadTokens(base64: string, bytesPerToken = IMAGE_BYTES_PER_TOKEN): number {
96
100
  if (base64.length === 0) return 0;
97
101
  const decoded = Math.floor((base64.length * 3) / 4);
98
- return Math.max(1, Math.ceil(decoded / IMAGE_BYTES_PER_TOKEN));
102
+ return Math.max(1, Math.ceil(decoded / bytesPerToken));
99
103
  }
100
104
 
101
105
  function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number {
@@ -1,6 +1,8 @@
1
1
  import type { OcxConfig } from "../../types";
2
2
  import { resolveComboId } from "../../combos";
3
- import { isBareOpenAiFamilyModel } from "../../router";
3
+ import { isBareOpenAiFamilyModel, previewRouteModel, routeConcreteModel } from "../../router";
4
+ import { resolveChosenTarget } from "./shadow-target-availability";
5
+ import { getRoutingProfile, resolvePolicyProfileId, type NormalizedRoutingProfile } from "../../routing/profile";
4
6
  import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models";
5
7
  import {
6
8
  getMainAccountHardLockStatus,
@@ -10,6 +12,9 @@ import { codexQuotaScopeForModel } from "../../codex/routing/health-store";
10
12
  import { getEligiblePoolAccounts } from "../../codex/routing/selection";
11
13
  import { OPENAI_CODEX_PROVIDER_ID, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
12
14
  import { providerCodexAccountMode } from "../../providers/registry";
15
+ import { FallbackRefusedError, guardFallbackHop, type FallbackAdmission, type FallbackTarget } from "../../routing/fallback-policy";
16
+ /** Resolves to a synchronous ownership proof, or null when the caller does not own the locked main. */
17
+ export type MainHardLockCallerCheck = (sourceModel: string) => Promise<(() => boolean) | null>;
13
18
 
14
19
  function isConfiguredComboSelector(config: OcxConfig, modelId: string): boolean {
15
20
  const comboId = resolveComboId(config, modelId);
@@ -72,11 +77,13 @@ function rawModelOf(body: unknown): { raw: Record<string, unknown>; model: strin
72
77
  * - Luna Reserve keeps its own admission policy (`assertReserveAdmission`); its helper
73
78
  * traffic is not operator chat and must not silently change models.
74
79
  */
75
- export function applyMainHardLockFallbackOverride(
80
+ export async function applyMainHardLockFallbackOverride(
76
81
  body: unknown,
77
82
  config: OcxConfig,
78
- now = Date.now(),
79
- ): MainHardLockFallbackOverride | null {
83
+ now?: number,
84
+ callerCheck?: MainHardLockCallerCheck,
85
+ admission?: FallbackAdmission,
86
+ ): Promise<MainHardLockFallbackOverride | null> {
80
87
  const parsed = rawModelOf(body);
81
88
  if (!parsed) return null;
82
89
  const fallback = resolveMainAccountHardLockFallback(config);
@@ -85,16 +92,51 @@ export function applyMainHardLockFallbackOverride(
85
92
  if (!isBareOpenAiFamilyModel(parsed.model)
86
93
  || parsed.model === NATIVE_RESERVE_MODEL
87
94
  || isConfiguredComboSelector(config, parsed.model)) return null;
88
- if (getMainAccountHardLockStatus(config, now).state !== "blocked") return null;
89
95
  const accountMode = providerCodexAccountMode(
90
96
  OPENAI_CODEX_PROVIDER_ID,
91
97
  config.providers?.[OPENAI_CODEX_PROVIDER_ID],
92
98
  );
93
- if (accountMode !== "direct"
94
- && getEligiblePoolAccounts(config, undefined, now, codexQuotaScopeForModel(parsed.model)).length > 0) {
95
- return null;
99
+ if (accountMode === "direct") {
100
+ // Direct may forward a different caller's healthy credential. Only stored-main substitution
101
+ // or an observed-main match owes this lock. Recheck usage after the startup binding wait.
102
+ const stillOwnsLockedMain = callerCheck ? await callerCheck(parsed.model) : null;
103
+ // Everything from here to the rewrite is synchronous, so this proof cannot go stale before it.
104
+ if (!stillOwnsLockedMain?.()) return null;
105
+ if (getMainAccountHardLockStatus(config, now ?? Date.now()).state !== "blocked") return null;
106
+ } else {
107
+ if (getMainAccountHardLockStatus(config, now ?? Date.now()).state !== "blocked") return null;
108
+ if (getEligiblePoolAccounts(config, undefined, now ?? Date.now(), codexQuotaScopeForModel(parsed.model)).length > 0) return null;
96
109
  }
97
110
 
111
+ // Combos and policies keep their own dispatch admission and declared failover/evidence.
112
+ // Other chosen targets must resolve now; a removed qualifier cannot become a default send.
113
+ const policyId = resolvePolicyProfileId(config, fallback.model);
114
+ if (!isConfiguredComboSelector(config, fallback.model)
115
+ && !(policyId && getRoutingProfile(config, policyId))) {
116
+ try {
117
+ const target = resolveChosenTarget(fallback.model, model => previewRouteModel(config, model));
118
+ if ("unavailable" in target) return null;
119
+ } catch {
120
+ // An unavailable target keeps the source lock's refusal without touching the body.
121
+ return null;
122
+ }
123
+ }
124
+
125
+ // OCX-11: the reroute is a proactive model hop; refused, the request is answered with the
126
+ // fallback refusal and the body is left untouched.
127
+ const hop = guardFallbackHop(config, {
128
+ site: "hard-lock",
129
+ route: parsed.model,
130
+ from: { provider: OPENAI_CODEX_PROVIDER_ID, model: parsed.model },
131
+ to: hardLockTargetOf(config, parsed.model, fallback.model),
132
+ trigger: "hard_lock",
133
+ admission,
134
+ ...(now !== undefined ? { now } : {}),
135
+ });
136
+ if (!hop.ok) throw new FallbackRefusedError(hop.response);
137
+ // The rewrite is this site's admission: the body now names the fallback model.
138
+ hop.commit();
139
+
98
140
  parsed.raw.model = fallback.model;
99
141
  if (fallback.reasoningEffort !== undefined) {
100
142
  const reasoning = parsed.raw.reasoning;
@@ -107,3 +149,38 @@ export function applyMainHardLockFallbackOverride(
107
149
  }
108
150
  return { sourceModel: parsed.model, targetModel: fallback.model };
109
151
  }
152
+
153
+ /**
154
+ * The hop's destination for the fallback policy, read from config only. A routing profile picks
155
+ * its candidate from request-history evidence, and evaluating it here would open that index under
156
+ * OPENCODEX_HOME just to classify the hop (on Windows the open handle then outlives the request's
157
+ * home); the profile's own dispatch still evaluates it. Its declared candidates are classified
158
+ * instead, as the hop the profile could take: a candidate on another provider makes it a provider
159
+ * hop. Combos, account namespaces and concrete targets resolve from config alone.
160
+ */
161
+ function hardLockTargetOf(config: OcxConfig, sourceModel: string, model: string): FallbackTarget {
162
+ const policyId = resolvePolicyProfileId(config, model);
163
+ const profile = policyId ? getRoutingProfile(config, policyId) : undefined;
164
+ if (profile) return profileHopTarget(config, sourceModel, profile);
165
+ try {
166
+ const route = previewRouteModel(config, model);
167
+ return { provider: route.providerName, model: route.modelId };
168
+ } catch {
169
+ const slash = model.indexOf("/");
170
+ return slash > 0 ? { provider: model.slice(0, slash), model: model.slice(slash + 1) } : { provider: "", model };
171
+ }
172
+ }
173
+
174
+ function profileHopTarget(config: OcxConfig, sourceModel: string, profile: NormalizedRoutingProfile): FallbackTarget {
175
+ const targets = profile.candidates.map((candidate): FallbackTarget => {
176
+ try {
177
+ const route = routeConcreteModel(config, candidate.provider + "/" + candidate.model);
178
+ return { provider: route.providerName, model: route.modelId };
179
+ } catch {
180
+ return { provider: candidate.provider, model: candidate.model };
181
+ }
182
+ });
183
+ return targets.find(target => target.provider !== OPENAI_CODEX_PROVIDER_ID)
184
+ ?? targets.find(target => target.model !== sourceModel)
185
+ ?? { provider: OPENAI_CODEX_PROVIDER_ID, model: sourceModel };
186
+ }