@bitkyc08/opencodex 2.88.0 → 2.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (291) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
  7. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +2 -2
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/base.ts +4 -0
  14. package/src/adapters/claude-cli/adapter.ts +13 -7
  15. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/coding-agent/turn.ts +14 -4
  18. package/src/adapters/cursor/protobuf-request.ts +3 -0
  19. package/src/adapters/google-http.ts +3 -2
  20. package/src/adapters/google-progress-history.ts +19 -1
  21. package/src/adapters/google-vertex-endpoint.ts +67 -0
  22. package/src/adapters/google.ts +62 -37
  23. package/src/adapters/image.ts +5 -3
  24. package/src/adapters/input-media-guard.ts +5 -2
  25. package/src/adapters/kiro-refusal.ts +41 -1
  26. package/src/adapters/ollama-native.ts +1 -0
  27. package/src/adapters/openai-chat/messages.ts +34 -4
  28. package/src/adapters/openai-chat/passthrough.ts +37 -4
  29. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  30. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  31. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  32. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  33. package/src/adapters/openai-chat.ts +7 -6
  34. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  35. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  36. package/src/adapters/openai-responses/passthrough.ts +22 -13
  37. package/src/adapters/openai-responses/reasoning.ts +68 -1
  38. package/src/adapters/openai-responses/transport.ts +13 -1
  39. package/src/adapters/registry.ts +1 -1
  40. package/src/adapters/upstream-http-error.ts +3 -2
  41. package/src/adapters/zed.ts +7 -4
  42. package/src/bridge/internal.ts +11 -1
  43. package/src/bridge/response-json.ts +2 -2
  44. package/src/bridge/sse.ts +10 -4
  45. package/src/claude/desktop-3p-library.ts +43 -3
  46. package/src/claude/desktop-3p.ts +20 -10
  47. package/src/claude/desktop-picker-profile.ts +18 -9
  48. package/src/claude/desktop-remote-store-io.ts +2 -2
  49. package/src/claude/inbound.ts +12 -26
  50. package/src/claude/outbound.ts +5 -0
  51. package/src/cli/account-api.ts +13 -1
  52. package/src/cli/account-extended.ts +17 -5
  53. package/src/cli/account-key-quota.ts +4 -1
  54. package/src/cli/account.ts +1 -1
  55. package/src/cli/capabilities-agents-routing.ts +45 -8
  56. package/src/cli/capabilities-base.ts +5 -5
  57. package/src/cli/capabilities-provider-models.ts +5 -4
  58. package/src/cli/combo-result.ts +8 -2
  59. package/src/cli/combo.ts +17 -0
  60. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  61. package/src/cli/help.ts +1 -1
  62. package/src/cli/message-command.ts +4 -1
  63. package/src/cli/message-remote-args.ts +69 -0
  64. package/src/cli/message-remote-command.ts +77 -0
  65. package/src/cli/provider-runtime.ts +69 -1
  66. package/src/cli/registry.ts +3 -3
  67. package/src/codex/account-lifecycle.ts +19 -6
  68. package/src/codex/account-pause.ts +6 -1
  69. package/src/codex/account-runtime-state.ts +5 -0
  70. package/src/codex/account-store.ts +55 -9
  71. package/src/codex/account-usability.ts +4 -1
  72. package/src/codex/auth-api/account-list.ts +13 -5
  73. package/src/codex/auth-api/account-pause-group.ts +2 -22
  74. package/src/codex/auth-api/login-collision.ts +59 -0
  75. package/src/codex/auth-api/login-flow.ts +12 -7
  76. package/src/codex/auth-api/main-account-probe.ts +3 -2
  77. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  78. package/src/codex/auth-collision.ts +31 -4
  79. package/src/codex/auth-context.ts +57 -10
  80. package/src/codex/catalog/aggregation.ts +15 -0
  81. package/src/codex/catalog/auto-review.ts +2 -1
  82. package/src/codex/catalog/build-entries.ts +17 -7
  83. package/src/codex/catalog/derive-entry.ts +3 -1
  84. package/src/codex/catalog/metadata.ts +2 -0
  85. package/src/codex/catalog/model-visibility.ts +0 -2
  86. package/src/codex/catalog/ownership.ts +71 -0
  87. package/src/codex/catalog/parsing.ts +12 -3
  88. package/src/codex/catalog/retained-sync.ts +2 -1
  89. package/src/codex/catalog/routed-removal.ts +4 -4
  90. package/src/codex/cli-update-plan.ts +18 -3
  91. package/src/codex/collision-identity.ts +78 -0
  92. package/src/codex/convergence.ts +2 -1
  93. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  94. package/src/codex/low-quota-protection.ts +57 -31
  95. package/src/codex/main-account-cache.ts +77 -3
  96. package/src/codex/main-account-hard-lock.ts +14 -0
  97. package/src/codex/main-account.ts +6 -2
  98. package/src/codex/native-residue.ts +2 -3
  99. package/src/codex/quota.ts +26 -2
  100. package/src/codex/routing/cooldown-math.ts +5 -0
  101. package/src/codex/routing/failure-window.ts +61 -9
  102. package/src/codex/routing.ts +4 -3
  103. package/src/codex/shim-diagnostics.ts +2 -1
  104. package/src/codex/shim-fingerprint.ts +11 -2
  105. package/src/codex/shim-templates.ts +16 -5
  106. package/src/codex/shim-windows-refresh.ts +224 -0
  107. package/src/codex/shim.ts +45 -21
  108. package/src/combos/failover.ts +10 -2
  109. package/src/combos/jev-model-backend.ts +30 -7
  110. package/src/combos/jev-model-request.ts +20 -0
  111. package/src/combos/jev-quota-activation.ts +153 -0
  112. package/src/combos/jev-quota-config.ts +62 -0
  113. package/src/combos/jev-quota-route.ts +26 -0
  114. package/src/combos/jev-quota.ts +88 -0
  115. package/src/combos/jev.ts +25 -12
  116. package/src/combos/types.ts +27 -0
  117. package/src/compatibility/index.ts +3 -0
  118. package/src/compatibility/openai-responses.ts +8 -1
  119. package/src/compatibility/opengateway-responses.ts +61 -0
  120. package/src/config/account-auto-pause-revisions.ts +136 -0
  121. package/src/config/diagnostics.ts +13 -0
  122. package/src/config/live-reconcile.ts +9 -0
  123. package/src/config/persist-unlocked.ts +10 -1
  124. package/src/config/provider-validation.ts +7 -2
  125. package/src/config/removed-opencode-free.ts +135 -0
  126. package/src/config/schema/account-auto-pause.ts +14 -0
  127. package/src/config/schema/config-schema.ts +15 -0
  128. package/src/config/schema/leaf-validators.ts +7 -0
  129. package/src/config.ts +10 -3
  130. package/src/generated/compatibility-version.json +476 -216
  131. package/src/generated/model-metadata.ts +1 -1
  132. package/src/images/loop.ts +34 -11
  133. package/src/lib/account-auto-pause-response.ts +16 -0
  134. package/src/lib/bun-runtime.ts +18 -1
  135. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  136. package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
  137. package/src/lib/decision-quota-sink.ts +76 -0
  138. package/src/lib/provider-admission-wait.ts +14 -0
  139. package/src/lib/service-secrets.ts +37 -2
  140. package/src/lib/state-store-registrations.ts +25 -0
  141. package/src/lib/translator-budget.ts +1 -1
  142. package/src/lib/upstream-retry.ts +15 -0
  143. package/src/link/ssh-argv.ts +3 -0
  144. package/src/messaging/envelope.ts +6 -3
  145. package/src/messaging/remote-auth.ts +83 -0
  146. package/src/messaging/remote-bridge.ts +270 -0
  147. package/src/messaging/remote-contract.ts +108 -0
  148. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  149. package/src/messaging/remote-enrollment.ts +244 -0
  150. package/src/messaging/remote-envelope.ts +40 -0
  151. package/src/messaging/remote-files.ts +117 -0
  152. package/src/messaging/remote-owner.ts +169 -0
  153. package/src/messaging/remote-ports.ts +56 -0
  154. package/src/messaging/remote-process.ts +131 -0
  155. package/src/messaging/remote-replies.ts +98 -0
  156. package/src/messaging/remote-rpc-admission.ts +32 -0
  157. package/src/messaging/remote-send.ts +72 -0
  158. package/src/messaging/remote-store.ts +111 -0
  159. package/src/messaging/remote-tunnels.ts +65 -0
  160. package/src/messaging/rpc.ts +13 -1
  161. package/src/oauth/account-auto-pause-policy.ts +66 -0
  162. package/src/oauth/account-auto-pause.ts +171 -0
  163. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  164. package/src/oauth/account-import/parser.ts +15 -2
  165. package/src/oauth/account-import/types.ts +7 -0
  166. package/src/oauth/account-quota-rank.ts +2 -1
  167. package/src/oauth/anthropic-account-refusal.ts +6 -0
  168. package/src/oauth/anthropic-model-family.ts +8 -0
  169. package/src/oauth/anthropic-model-quota.ts +2 -6
  170. package/src/oauth/api-key-pool-preserve.ts +37 -0
  171. package/src/oauth/chatgpt-shared.ts +10 -1
  172. package/src/oauth/dsh.ts +45 -2
  173. package/src/oauth/index.ts +5 -35
  174. package/src/oauth/kiro.ts +44 -1
  175. package/src/oauth/login-flow-state.ts +11 -2
  176. package/src/oauth/store.ts +35 -8
  177. package/src/oauth/types.ts +2 -0
  178. package/src/plugins/upstream-hooks.ts +14 -1
  179. package/src/providers/api-key-pause.ts +32 -0
  180. package/src/providers/api-key-resolve.ts +10 -4
  181. package/src/providers/api-key-selection.ts +6 -1
  182. package/src/providers/api-keys.ts +8 -4
  183. package/src/providers/claude-cli-usage.ts +25 -5
  184. package/src/providers/initial-model-selection.ts +2 -2
  185. package/src/providers/key-failover.ts +21 -13
  186. package/src/providers/key-store.ts +8 -3
  187. package/src/providers/mistral-transport.ts +207 -0
  188. package/src/providers/model-rename-fields.ts +1 -0
  189. package/src/providers/openai-sidecar.ts +5 -0
  190. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  191. package/src/providers/quota/account-cache.ts +15 -1
  192. package/src/providers/quota/anthropic-account-quota.ts +20 -7
  193. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  194. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  195. package/src/providers/quota/antigravity.ts +45 -8
  196. package/src/providers/quota/failure-codes.ts +18 -0
  197. package/src/providers/quota/report-cache.ts +14 -0
  198. package/src/providers/quota/vendor-probes-oauth.ts +15 -6
  199. package/src/providers/quota-decision-publication.ts +77 -0
  200. package/src/providers/quota-decision-snapshot.ts +145 -0
  201. package/src/providers/quota-routing-cache.ts +4 -1
  202. package/src/providers/quota-types.ts +6 -1
  203. package/src/providers/quota.ts +8 -3
  204. package/src/providers/registry/entries-extended.ts +14 -53
  205. package/src/providers/registry/model-seeds.ts +1 -1
  206. package/src/providers/registry.ts +1 -2
  207. package/src/providers/routed-config.ts +1 -1
  208. package/src/providers/stale-vision-classification-migration.ts +1 -1
  209. package/src/providers/xai-transport.ts +2 -1
  210. package/src/responses/audio.ts +46 -0
  211. package/src/responses/input-media.ts +37 -15
  212. package/src/responses/parser-content.ts +22 -27
  213. package/src/responses/parser.ts +18 -10
  214. package/src/responses/reasoning-retention.ts +218 -11
  215. package/src/responses/schema.ts +3 -4
  216. package/src/responses/web-search-replay.ts +147 -0
  217. package/src/routing/compatibility/behavior.ts +4 -1
  218. package/src/server/auth-cors.ts +7 -0
  219. package/src/server/background-lifecycle.ts +2 -1
  220. package/src/server/chat-native.ts +36 -20
  221. package/src/server/claude-messages.ts +2 -1
  222. package/src/server/index/optional-listeners.ts +6 -0
  223. package/src/server/index/serve-options.ts +8 -2
  224. package/src/server/index/websocket-handler.ts +10 -0
  225. package/src/server/lifecycle.ts +17 -1
  226. package/src/server/management/combo-routes.ts +17 -2
  227. package/src/server/management/oauth-account-routes.ts +12 -2
  228. package/src/server/management/provider-completion-test.ts +289 -0
  229. package/src/server/management/provider-routes.ts +9 -1
  230. package/src/server/management/route-registry.ts +2 -0
  231. package/src/server/messages-native.ts +29 -4
  232. package/src/server/messages-shared.ts +4 -1
  233. package/src/server/request-log.ts +23 -9
  234. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  235. package/src/server/responses/adapter-continuation.ts +2 -2
  236. package/src/server/responses/adapter-dispatch.ts +79 -30
  237. package/src/server/responses/codex-ws-exchange.ts +3 -0
  238. package/src/server/responses/codex-ws-metadata.ts +44 -0
  239. package/src/server/responses/codex-ws-session.ts +21 -1
  240. package/src/server/responses/combo-session-recall.ts +25 -0
  241. package/src/server/responses/combo-stream-preflight.ts +10 -3
  242. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  243. package/src/server/responses/compact.ts +10 -5
  244. package/src/server/responses/context-overflow.ts +141 -2
  245. package/src/server/responses/core-auth.ts +20 -0
  246. package/src/server/responses/core-codex-account.ts +28 -15
  247. package/src/server/responses/core-combo.ts +34 -5
  248. package/src/server/responses/core-opaque-recovery.ts +20 -11
  249. package/src/server/responses/core.ts +12 -9
  250. package/src/server/responses/deepseek-image-files.ts +121 -0
  251. package/src/server/responses/empty-completion-guard.ts +10 -0
  252. package/src/server/responses/fetch-helpers.ts +65 -14
  253. package/src/server/responses/input-admission.ts +6 -2
  254. package/src/server/responses/jev-model-invoke.ts +13 -14
  255. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  256. package/src/server/responses/native-steering-log.ts +2 -2
  257. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  258. package/src/server/responses/passthrough-completion.ts +41 -0
  259. package/src/server/responses/passthrough-delivery.ts +5 -2
  260. package/src/server/responses/passthrough-dispatch.ts +42 -45
  261. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  262. package/src/server/responses/passthrough-observations.ts +38 -0
  263. package/src/server/responses/reasoning-archive-error.ts +21 -0
  264. package/src/server/responses/request-compression.ts +98 -0
  265. package/src/server/responses/request-prepare.ts +11 -1
  266. package/src/server/responses/request-transport.ts +92 -5
  267. package/src/server/responses/run-turn-execution.ts +2 -0
  268. package/src/server/responses/sidecar-execution.ts +16 -3
  269. package/src/server/responses/terminal-guard.ts +2 -0
  270. package/src/server/startup-action-control.ts +2 -2
  271. package/src/server/startup-health-cache.ts +2 -2
  272. package/src/server/windows-tray-control.ts +2 -2
  273. package/src/service/definition.ts +9 -1
  274. package/src/service/orchestration.ts +17 -0
  275. package/src/types/account-auto-pause.ts +15 -0
  276. package/src/types/config.ts +23 -1
  277. package/src/types/provider.ts +11 -5
  278. package/src/types/request.ts +36 -3
  279. package/src/types.ts +2 -0
  280. package/src/usage/cost.ts +17 -1
  281. package/src/usage/expected-prices.ts +3 -1
  282. package/src/usage/jev-stats.ts +4 -0
  283. package/src/usage/log.ts +2 -0
  284. package/src/usage/summary.ts +7 -0
  285. package/src/web-search/loop.ts +39 -13
  286. package/src/web-search/progress-stream.ts +4 -1
  287. package/src/web-search/run-turn-loop.ts +9 -3
  288. package/src/web-search/sidecar-search-replay.ts +96 -0
  289. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  290. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  291. package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
@@ -110,6 +110,31 @@ export function recallComboForLane(
110
110
  return entry.responseModel === model ? entry.comboId : undefined;
111
111
  }
112
112
 
113
+ /**
114
+ * The concrete target that completed the lane's last turn for `comboId`, under the same
115
+ * freshness and ownership rules as `recallComboForLane`. A lane answered by another combo, or
116
+ * whose target left the combo or was disabled, recalls nothing.
117
+ */
118
+ export function recallComboTargetForLane(
119
+ config: OcxConfig,
120
+ lane: string | undefined,
121
+ comboId: string,
122
+ ): Pick<OcxComboTarget, "provider" | "model"> | undefined {
123
+ if (!lane || !comboId) return undefined;
124
+ const entry = recall.get(lane);
125
+ if (!entry || entry.comboId !== comboId) return undefined;
126
+ const combo = getCombo(config, entry.comboId);
127
+ const provider = config.providers[entry.target.provider];
128
+ if (Date.now() - entry.at >= RECALL_TTL_MS
129
+ || !Object.hasOwn(config.providers, entry.target.provider)
130
+ || !provider || provider.disabled === true
131
+ || !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) {
132
+ deleteEntry(lane);
133
+ return undefined;
134
+ }
135
+ return { provider: entry.target.provider, model: entry.target.model };
136
+ }
137
+
113
138
  /**
114
139
  * Periodic expiry. Without it a lane that is never read again and never touched by a config
115
140
  * reconciliation holds its entry for the life of the process: the existing TTL is only
@@ -115,12 +115,14 @@ function retryableZeroOutputTerminal(payload: unknown): boolean {
115
115
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
116
116
  const event = payload as {
117
117
  type?: unknown;
118
- response?: { incomplete_details?: { reason?: unknown } };
118
+ response?: { incomplete_details?: { reason?: unknown }; output?: unknown };
119
119
  };
120
120
  if (bareErrorIsRetryable(event)) return true;
121
121
  if (event.type === "response.failed") return true;
122
122
  if (event.type !== "response.incomplete") return false;
123
123
  const reason = event.response?.incomplete_details?.reason;
124
+ // A terminal-only upstream can carry output without preceding delta/item events.
125
+ if (reason === undefined) return !Array.isArray(event.response?.output) || event.response.output.length === 0;
124
126
  return typeof reason === "string" && RETRYABLE_ZERO_OUTPUT_INCOMPLETE_REASONS.has(reason);
125
127
  }
126
128
 
@@ -222,8 +224,13 @@ function failedTerminalResponse(
222
224
  : topLevelError && typeof topLevelError === "object" && !Array.isArray(topLevelError)
223
225
  ? topLevelError as Record<string, unknown>
224
226
  : {
225
- type: "upstream_error",
226
- code: "upstream_server_error",
227
+ // A caller-validated flat error can carry its identity beside type:error.
228
+ // Retain it when that caller established a 400, rather than discarding the
229
+ // rejection code before the single-shot opaque-state recovery sees it.
230
+ type: terminalPayload.type === "error" && logCtx.terminalHttpStatus === 400
231
+ ? "invalid_request_error" : "upstream_error",
232
+ code: terminalPayload.type === "error" && logCtx.terminalHttpStatus === 400
233
+ ? (typeof terminalPayload.code === "string" ? terminalPayload.code : null) : "upstream_server_error",
227
234
  message: typeof terminalPayload.message === "string"
228
235
  ? terminalPayload.message
229
236
  : logCtx.upstreamError ?? "Provider stream failed before producing output",
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Combo target pinning for an in-flight tool loop (#7298).
3
+ *
4
+ * A combo picks a target per request. A tool loop is several requests that together form one
5
+ * assistant turn: the model emits a call, the client runs it, and the next request carries the
6
+ * result back. Re-picking between those requests hands the second half of a turn to a model that
7
+ * did not produce the first half. That is wasteful at best (a cold prompt cache, a different
8
+ * plan) and a hard failure at worst: DeepSeek's thinking mode refuses a tool continuation whose
9
+ * call carries no reasoning it can read, and a GPT turn never leaves one.
10
+ *
11
+ * So a continuation whose input ends in a tool output keeps the target that completed the lane's
12
+ * previous turn, as long as that target is still selectable right now: enabled, still in the
13
+ * combo, out of cooldown and passing every per-request eligibility filter the caller supplies.
14
+ * When it is not, normal selection (and normal failover) runs, and the strict serializers make
15
+ * the foreign history acceptable instead: see `backfillBareToolCallTurnReasoning` and the Chat
16
+ * adapter's reasoning placeholder.
17
+ *
18
+ * A new user message is never pinned; re-deciding per user turn is what a combo is for.
19
+ */
20
+ import { pickComboTarget, targetKey, type ComboPick } from "../../combos";
21
+ import type { NormalizedComboTarget } from "../../combos/types";
22
+ import type { OcxConfig } from "../../types";
23
+ import { recallComboTargetForLane } from "./combo-session-recall";
24
+
25
+ export const COMBO_TOOL_LOOP_PIN_REASON = "combo-tool-loop-pin";
26
+
27
+ function isToolOutputItem(item: unknown): boolean {
28
+ if (!item || typeof item !== "object" || Array.isArray(item)) return false;
29
+ const type = (item as { type?: unknown }).type;
30
+ return typeof type === "string" && type.endsWith("_output");
31
+ }
32
+
33
+ /**
34
+ * Whether this request resumes an assistant turn by returning tool results: its Responses
35
+ * `input` (or Chat `messages`) ends with a tool output.
36
+ */
37
+ export function isToolLoopContinuation(body: unknown): boolean {
38
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
39
+ const { input, messages } = body as { input?: unknown; messages?: unknown };
40
+ if (Array.isArray(input)) return input.length > 0 && isToolOutputItem(input[input.length - 1]);
41
+ if (Array.isArray(messages) && messages.length > 0) {
42
+ const last = messages[messages.length - 1];
43
+ return !!last && typeof last === "object" && (last as { role?: unknown }).role === "tool";
44
+ }
45
+ return false;
46
+ }
47
+
48
+ /**
49
+ * The pick that keeps a tool loop on its previous target, or undefined to select normally.
50
+ * Uses a non-mutating preview so a pinned request never disturbs round-robin rotation state.
51
+ */
52
+ export function toolLoopPinnedPick(args: {
53
+ config: OcxConfig;
54
+ comboId: string;
55
+ lane: string | undefined;
56
+ body: unknown;
57
+ eligible: (target: NormalizedComboTarget) => boolean;
58
+ now?: number;
59
+ }): ComboPick | undefined {
60
+ if (!isToolLoopContinuation(args.body)) return undefined;
61
+ const previous = recallComboTargetForLane(args.config, args.lane, args.comboId);
62
+ if (!previous) return undefined;
63
+ const previousKey = targetKey(previous);
64
+ const pick = pickComboTarget(args.config, args.comboId, {
65
+ eligible: target => targetKey(target) === previousKey && args.eligible(target),
66
+ now: args.now,
67
+ preview: true,
68
+ });
69
+ return pick ? { ...pick, attempted: [previousKey] } : undefined;
70
+ }
@@ -1,3 +1,4 @@
1
+ import { captureAccountAutoPauseAttempt, wasAccountAutoPaused, wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
1
2
  import { spendLedgerStorageErrorResponse } from "./spend-storage-error";
2
3
  import { createPhysicalSendReporter, rebindPhysicalSend } from "../../lib/request-execution-budget";
3
4
  import { createInferenceSendBudget } from "../inference/context";
@@ -87,6 +88,7 @@ import {
87
88
  recordCodexUpstreamOutcome,
88
89
  type CodexUpstreamOutcome,
89
90
  } from "../../codex/routing";
91
+ import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
90
92
  import {
91
93
  applyAccountChangeConversationStateScrub,
92
94
  conversationStateBindingFromAuth,
@@ -1058,8 +1060,10 @@ export async function handleResponsesCompact(
1058
1060
  ) => {
1059
1061
  // A gateway response is not evidence about any Codex account.
1060
1062
  if (gatewayTransport || !usesCodexForwardPoolAuth(ctx, route.provider)) return;
1063
+ if ((outcome === 401 || outcome === 403) && upstream && wasPermanentAccountRefusal(upstream)) return;
1061
1064
  recordCodexUpstreamOutcome(config, ctx.accountId, outcome, {
1062
1065
  ...meta,
1066
+ ...codexFailureWindowOutcomeMeta(ctx),
1063
1067
  threadId: ctx.kind === "pool" || ctx.kind === "main-pool" ? ctx.affinityKey : undefined,
1064
1068
  fixedAccount: ctx.fixedAccount,
1065
1069
  modelId: selectedModelId,
@@ -1166,6 +1170,7 @@ export async function handleResponsesCompact(
1166
1170
  providerFetch(sendProvider, undefined, {
1167
1171
  providerName: route.providerName,
1168
1172
  modelId: route.modelId,
1173
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: sendAuthCtx }, headers),
1169
1174
  beforeDispatch: isCanonicalOpenAiForwardProvider(sendProvider)
1170
1175
  ? createCodexAuthDispatchGuard(sendAuthCtx, config, selectedModelId, admission) : undefined,
1171
1176
  dispatchOverride: sendProvider.authMode !== "forward" && sendProvider.apiKey
@@ -1287,7 +1292,7 @@ export async function handleResponsesCompact(
1287
1292
 
1288
1293
  recordDistributedResponseFailure(upstream, config, route.providerName, req.signal);
1289
1294
  if (
1290
- upstream.status === 401
1295
+ upstream.status === 401 && !wasPermanentAccountRefusal(upstream)
1291
1296
  && (authCtx.kind === "main-pool" || authCtx.kind === "pool")
1292
1297
  && !gatewayTransport
1293
1298
  && usesCodexForwardPoolAuth(authCtx, compactProvider)
@@ -1411,12 +1416,12 @@ export async function handleResponsesCompact(
1411
1416
  // surfaces to the client, which retries the compact task OUTSIDE the logical request
1412
1417
  // — reporting exhausted retries while another pool account sat idle (#913).
1413
1418
  if (
1414
- (upstream.status === 429 || upstream.status === 402)
1419
+ (upstream.status === 429 || upstream.status === 402 || wasAccountAutoPaused(upstream))
1415
1420
  // A replay refusal this proxy synthesized carries 429 for the client's benefit only.
1416
1421
  // It is not pool quota evidence, and the alternate account below is another send of a
1417
1422
  // compact turn that may already have been processed.
1418
1423
  && !isNonReplayableResponse(upstream)
1419
- && !storedPool401ReplayAttempted
1424
+ && (!storedPool401ReplayAttempted || wasAccountAutoPaused(upstream))
1420
1425
  && !gatewayTransport
1421
1426
  && usesCodexForwardPoolAuth(authCtx, route.provider)
1422
1427
  && !authCtx.fixedAccount
@@ -1521,7 +1526,7 @@ export async function handleResponsesCompact(
1521
1526
  // the same guards as the quota move, and a 5xx whose body confirms quota keeps today's path.
1522
1527
  if (
1523
1528
  shouldRetryCodexPoolAccountTransient(upstream)
1524
- && !storedPool401ReplayAttempted
1529
+ && (!storedPool401ReplayAttempted || wasAccountAutoPaused(upstream))
1525
1530
  && !gatewayTransport
1526
1531
  && usesCodexForwardPoolAuth(authCtx, route.provider)
1527
1532
  && !authCtx.fixedAccount
@@ -1642,7 +1647,7 @@ export async function handleResponsesCompact(
1642
1647
  inspectResponseLogJson(logCtx, compactText);
1643
1648
  if (!options.compactionRoutingOverride) forgetCompactHandoffRoute(req, admission);
1644
1649
  rememberServingConversationStateIssuer(outcomeCtx, codexPoolAffinityKey(req.headers));
1645
- } else if (!options.compactionRoutingOverride && quotaFailure && !storedPool401ReplayAttempted) {
1650
+ } else if (!options.compactionRoutingOverride && quotaFailure && (!storedPool401ReplayAttempted || wasAccountAutoPaused(upstream))) {
1646
1651
  const fallbackModel = compactHandoffRoute(req, admission, raw.model);
1647
1652
  if (fallbackModel && !req.signal.aborted) {
1648
1653
  const fallbackReq = new Request(req.url, {
@@ -1,5 +1,8 @@
1
1
  import { bridgeToResponsesSSE, formatErrorResponse } from "../../bridge";
2
2
  import { OLLAMA_LATE_ATTRIBUTION_LIMIT_MESSAGE } from "../../adapters/ollama-native";
3
+ import { isThroughputLimitText } from "../../claude/outbound";
4
+ import { isDefiniteContextOverflow } from "../../combos/failover";
5
+ import { classifyError } from "../../lib/errors";
3
6
  import type { TranslatorBudget } from "../../lib/translator-budget";
4
7
  import type { AdapterEvent } from "../../types";
5
8
 
@@ -32,8 +35,11 @@ export function jsonContextOverflowResponse(message = PROVIDER_INPUT_TOO_LARGE_M
32
35
  }, { status: 413, headers: { "Cache-Control": "no-store" } });
33
36
  }
34
37
 
35
- async function* contextOverflowEvents(message: string): AsyncGenerator<AdapterEvent> {
36
- yield {
38
+ /** The terminal error event every context overflow is reported as, streaming or in-stream. */
39
+ export function contextOverflowEvent(
40
+ message = PROVIDER_INPUT_TOO_LARGE_MESSAGE,
41
+ ): Extract<AdapterEvent, { type: "error" }> {
42
+ return {
37
43
  type: "error",
38
44
  message,
39
45
  status: 413,
@@ -43,6 +49,139 @@ async function* contextOverflowEvents(message: string): AsyncGenerator<AdapterEv
43
49
  };
44
50
  }
45
51
 
52
+ async function* contextOverflowEvents(message: string): AsyncGenerator<AdapterEvent> {
53
+ yield contextOverflowEvent(message);
54
+ }
55
+
56
+ /**
57
+ * Whether a web-search or image-bridge failure is the provider refusing an oversized input.
58
+ *
59
+ * An HTTP 413 is classified by status alone, exactly as the main path does. A 400/422 needs a
60
+ * definite overflow phrase in the provider message (the rule combo failover already trusts), so a
61
+ * plain bad request stays what it was, and rate-window wording ("too many tokens per minute")
62
+ * never reads as an overflow: compacting does not fix a throughput limit. An adapter that already
63
+ * classified its own failure as `context_length_exceeded` keeps that verdict whatever status it
64
+ * rode on. The local translator limit is excluded: compacting cannot shrink a buffer this proxy owns.
65
+ */
66
+ export function isSidecarContextOverflow(
67
+ status: number | undefined,
68
+ message: string,
69
+ code?: string | null,
70
+ ): boolean {
71
+ if (code === "translation_buffer_limit" || code === "rate_limit_exceeded") return false;
72
+ if (code === "context_length_exceeded") return true;
73
+ if (status === 413) return true;
74
+ return (status === 400 || status === 422) && !isThroughputLimitText(message)
75
+ && isDefiniteContextOverflow(status, message);
76
+ }
77
+
78
+ /**
79
+ * The in-stream terminal for a sidecar overflow. A bare 413 carries no provider wording worth
80
+ * keeping and gets the proxy-owned message. A worded verdict keeps its own status and message:
81
+ * that is the evidence combo failover reads to try a larger-context target, and replacing it
82
+ * would end the chain at the first target that was merely too small.
83
+ */
84
+ export function sidecarContextOverflowEvent(
85
+ status: number | undefined,
86
+ message: string,
87
+ ): Extract<AdapterEvent, { type: "error" }> {
88
+ return status === undefined || !message || (status === 413 && !isDefiniteContextOverflow(413, message))
89
+ ? contextOverflowEvent()
90
+ : { ...contextOverflowEvent(message), status };
91
+ }
92
+
93
+ /** One sidecar loop failure, with what the loop knew when it raised it. */
94
+ export interface SidecarLoopFailure {
95
+ status: number;
96
+ message: string;
97
+ event?: Extract<AdapterEvent, { type: "error" }>;
98
+ /** The provider's own `error.code`, read from a bounded display-safe body. */
99
+ upstreamCode?: string;
100
+ /** The failed response was marked non-replayable: the provider may already have run the turn. */
101
+ nonReplayable?: boolean;
102
+ }
103
+
104
+ /**
105
+ * The overflow terminal a sidecar loop failure should end with, or `undefined` to keep the
106
+ * failure as it is. A non-replayable failure keeps its verdict: an overflow would invite a
107
+ * compacted resend of a turn the provider may already have run. An adapter event marked
108
+ * `retryable: false` under any other code is that adapter's own verdict and is not reread.
109
+ */
110
+ export function sidecarLoopOverflowEvent(
111
+ failure: SidecarLoopFailure,
112
+ ): Extract<AdapterEvent, { type: "error" }> | undefined {
113
+ if (failure.nonReplayable) return undefined;
114
+ const code = failure.event?.code ?? failure.upstreamCode;
115
+ if (failure.event?.retryable === false && code !== "context_length_exceeded") return undefined;
116
+ if (!isSidecarContextOverflow(failure.status, failure.message, code)) return undefined;
117
+ return sidecarContextOverflowEvent(failure.event?.status ?? failure.status, failure.message);
118
+ }
119
+
120
+ /**
121
+ * The in-stream terminal for a sidecar loop failure that carries no adapter event, or
122
+ * `undefined` to keep the loop's existing generic event. An overflow ends as the overflow
123
+ * terminal. A failure this classifier deliberately kept (non-replayable, or an upstream
124
+ * `rate_limit_exceeded`) gets an explicit code whenever the bridge's wording heuristics would
125
+ * otherwise rename it `context_length_exceeded` and undo that verdict on the wire.
126
+ */
127
+ export function sidecarLoopTerminalEvent(
128
+ failure: SidecarLoopFailure,
129
+ ): Extract<AdapterEvent, { type: "error" }> | undefined {
130
+ const overflow = sidecarLoopOverflowEvent(failure);
131
+ if (overflow || failure.event) return overflow;
132
+ if (classifyError(failure.status, "upstream_error", failure.message).code !== "context_length_exceeded") return undefined;
133
+ const rateLimited = failure.upstreamCode === "rate_limit_exceeded";
134
+ return {
135
+ type: "error",
136
+ status: failure.status,
137
+ message: failure.message,
138
+ errorType: rateLimited ? "rate_limit_error" : "upstream_error",
139
+ code: failure.upstreamCode ?? "upstream_error",
140
+ ...(failure.nonReplayable ? { retryable: false } : {}),
141
+ };
142
+ }
143
+
144
+ /** Read a provider's `error.code` from an already bounded, display-safe error body. */
145
+ export function upstreamErrorCode(text: string): string | undefined {
146
+ try {
147
+ const code = (JSON.parse(text) as { error?: { code?: unknown } } | null)?.error?.code;
148
+ return typeof code === "string" && code.length <= 128 ? code : undefined;
149
+ } catch {
150
+ return undefined;
151
+ }
152
+ }
153
+
154
+ const sidecarOverflowResponses = new WeakSet<Response>();
155
+
156
+ /** Tag a loop's first-iteration JSON failure that the sidecar boundary should map to an overflow. */
157
+ export function markSidecarContextOverflow(response: Response, failure: SidecarLoopFailure): Response {
158
+ if (sidecarLoopOverflowEvent(failure)) sidecarOverflowResponses.add(response);
159
+ return response;
160
+ }
161
+
162
+ /**
163
+ * Map a sidecar loop's pre-stream HTTP failure onto the main path's overflow contract (#7316).
164
+ *
165
+ * Both loops answer a failed first iteration with their own small JSON error, so the overflow
166
+ * would otherwise reach Codex as a bare non-2xx that it resends unchanged through its retry
167
+ * budget. The loop classified the failure while it still had the provider's status, wording,
168
+ * code and replay verdict, and tagged the response; anything untagged is returned untouched.
169
+ * Combo attempts do not come through here: the combo layer reads the raw failure for its hop
170
+ * decision and maps a final 413 itself. A failure answered with `x-should-retry: false` keeps
171
+ * its status and header, so that contract never turns into an invitation to resend.
172
+ */
173
+ export function sidecarContextOverflowResponse(
174
+ response: Response,
175
+ options: { stream: boolean; modelId: string; translatorBudget: TranslatorBudget },
176
+ ): Response {
177
+ if (response.ok || !sidecarOverflowResponses.has(response)) return response;
178
+ if (response.headers.get("x-should-retry") === "false") return response;
179
+ void response.body?.cancel().catch(() => {});
180
+ return options.stream
181
+ ? streamingContextOverflowResponse(options.modelId, options.translatorBudget)
182
+ : jsonContextOverflowResponse();
183
+ }
184
+
46
185
  /**
47
186
  * Convert a pre-stream provider 413 into the terminal Responses event Codex understands.
48
187
  *
@@ -2,6 +2,7 @@ import type { OcxProviderConfig, OcxConfig } from "../../types";
2
2
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
3
3
  import type { CodexAuthContext } from "../../codex/auth-context";
4
4
  import type { RouteResult } from "../../router";
5
+ import { routeConcreteModel } from "../../router";
5
6
  import type { HandleResponsesOptions } from "./core-options";
6
7
  import {
7
8
  hasForwardableCodexBearer,
@@ -24,6 +25,7 @@ import {
24
25
  CodexAuthContextError,
25
26
  applyCodexAuthContextToProvider,
26
27
  stripCodexRuntimeProviderFields,
28
+ mainAccountHardLockCallerProof,
27
29
  } from "../../codex/auth-context";
28
30
  import { codexAccountSelectionForTurn, tryClaimNativeMainProfileForTurn } from "../lifecycle";
29
31
  import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
@@ -590,3 +592,21 @@ export async function refreshNativeMainForwardAuth(args: {
590
592
  }) ?? nativeMainRefreshFailureResponse(error) };
591
593
  }
592
594
  }
595
+
596
+ /** Inspect the original credential domain, before fallback changes the route or drops headers. */
597
+ export function mainHardLockFallbackCallerCheck(
598
+ req: Request, config: OcxConfig, options: HandleResponsesOptions,
599
+ ): (sourceModel: string) => Promise<(() => boolean) | null> {
600
+ return async sourceModel => {
601
+ let sourceRoute: ReturnType<typeof routeConcreteModel>;
602
+ try { sourceRoute = routeConcreteModel(config, sourceModel); }
603
+ // This preview cannot replace the normal routing phase's existing error response.
604
+ catch { return null; }
605
+ const headers = codexRouteCredentialDomainHeaders(req, sourceRoute, options, false, config);
606
+ const { substituteMainCredential } = codexRouteCredentialOwnership(headers, config, sourceRoute, options);
607
+ return mainAccountHardLockCallerProof(headers, options.codexAuthPolicy ?? config, {
608
+ substituteMainCredentialForDirect: substituteMainCredential,
609
+ signal: options.abortSignal ?? req.signal,
610
+ });
611
+ };
612
+ }
@@ -1,3 +1,4 @@
1
+ import { captureAccountAutoPauseAttempt, wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
1
2
  import type { OcxConfig, OcxProviderConfig, OcxParsedRequest } from "../../types";
2
3
  import type { CodexAuthContext, CodexAuthPolicyConfig } from "../../codex/auth-context";
3
4
  import type { CodexUpstreamOutcome } from "../../codex/routing";
@@ -10,6 +11,7 @@ import { claimMainQuotaDispatchForWs, isMainQuotaDispatchLive, renewMainQuotaDis
10
11
  import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
11
12
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
12
13
  import { isCodexAccountGenerationLive } from "../../codex/account-store";
14
+ import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
13
15
  import { applyAccountQuotaFromUpstreamHeaders as applyCapturedCodexQuota } from "../../codex/quota";
14
16
  import type { RouteResult } from "../../router";
15
17
  import {
@@ -92,20 +94,19 @@ export function sidecarOutcomeRecorder(
92
94
  config: OcxConfig,
93
95
  authCtx: CodexAuthContext,
94
96
  ): ((outcome: CodexUpstreamOutcome) => void) | undefined {
95
- return authCtx.kind === "pool" || authCtx.kind === "main-pool"
96
- ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
97
- threadId: authCtx.affinityKey,
98
- fixedAccount: authCtx.fixedAccount,
99
- probeLeaseId: authCtx.probeLeaseId,
100
- probeQuotaScope: authCtx.probeQuotaScope,
101
- writerGeneration: authCtx.writerGeneration,
102
- // A vision or web-search sidecar can return 401/403, and that is evidence about the exact
103
- // stored credential it used. Without the generation it becomes an account-wide quarantine
104
- // that a replacement inherits (#2892 gap 4). `main-pool` has no stored-record generation, so
105
- // it keeps the unfenced account-wide semantics.
106
- ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}),
107
- })
108
- : undefined;
97
+ if (authCtx.kind !== "pool" && authCtx.kind !== "main-pool") return undefined;
98
+ const windowMeta = codexFailureWindowOutcomeMeta(authCtx);
99
+ return outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
100
+ ...windowMeta,
101
+ threadId: authCtx.affinityKey,
102
+ fixedAccount: authCtx.fixedAccount,
103
+ probeLeaseId: authCtx.probeLeaseId,
104
+ probeQuotaScope: authCtx.probeQuotaScope,
105
+ writerGeneration: authCtx.writerGeneration,
106
+ // Quarantine retains its stored-credential fence. main-pool has no stored-record
107
+ // generation; its separate dispatch proof above fences only failure-window samples.
108
+ ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}),
109
+ });
109
110
  }
110
111
 
111
112
 
@@ -144,9 +145,10 @@ export function recordCodexPreOutputRefusalOutcome(
144
145
  modelId: string,
145
146
  ): void {
146
147
  if (!isCodexWsPreOutputRefusal(response) || codexWsPreOutputRefusalOutcomeRecorded(response)) return;
147
- if (!usesCodexForwardPoolAuth(authCtx, provider)) return;
148
+ if (!usesCodexForwardPoolAuth(authCtx, provider) || wasPermanentAccountRefusal(response)) return;
148
149
  noteCodexWsPreOutputRefusalOutcome(response);
149
150
  recordCodexUpstreamOutcome(config, authCtx.accountId, response.status, {
151
+ ...codexFailureWindowOutcomeMeta(authCtx),
150
152
  threadId: authCtx.affinityKey,
151
153
  fixedAccount: authCtx.fixedAccount,
152
154
  modelId,
@@ -675,6 +677,7 @@ export async function retryCodexPoolOnAlternateAccount(
675
677
  if (codexWsPreOutputRefusalOutcomeRecorded(firstResponse)) return;
676
678
  noteCodexWsPreOutputRefusalOutcome(firstResponse);
677
679
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
680
+ ...codexFailureWindowOutcomeMeta(firstAuthCtx),
678
681
  threadId: firstAuthCtx.affinityKey,
679
682
  fixedAccount: firstAuthCtx.fixedAccount,
680
683
  modelId: route.modelId,
@@ -692,6 +695,7 @@ export async function retryCodexPoolOnAlternateAccount(
692
695
  if (outcomeStatus === firstResponse.status || (outcomeStatus !== 429 && outcomeStatus !== 402)) return;
693
696
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
694
697
  ...codexQuotaOutcomeMeta(firstResponse),
698
+ ...codexFailureWindowOutcomeMeta(firstAuthCtx),
695
699
  threadId: firstAuthCtx.affinityKey,
696
700
  modelId: route.modelId,
697
701
  probeLeaseId: codexProbeLeaseId(firstAuthCtx),
@@ -855,8 +859,13 @@ export async function retryCodexPoolOnAlternateAccount(
855
859
  options.deferCodexResetDerivedCooldown,
856
860
  );
857
861
  const recordFirstOutcome = (): void => {
862
+ if (wasPermanentAccountRefusal(firstResponse)) {
863
+ noteCodexWsPreOutputRefusalOutcome(firstResponse);
864
+ return;
865
+ }
858
866
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
859
867
  ...quotaMeta,
868
+ ...codexFailureWindowOutcomeMeta(firstAuthCtx),
860
869
  threadId: firstAuthCtx.affinityKey,
861
870
  modelId: route.modelId,
862
871
  probeLeaseId: codexProbeLeaseId(firstAuthCtx),
@@ -1032,6 +1041,7 @@ export async function retryCodexPoolOnAlternateAccount(
1032
1041
  providerName: route.providerName,
1033
1042
  modelId: route.modelId,
1034
1043
  preOutputRefusalHold: args.preOutputRefusalHold,
1044
+ accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: retryAuthCtx }, headers),
1035
1045
  onCodexWsQuota: codexWsQuotaObserver(retryAuthCtx, route.provider, route.modelId),
1036
1046
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
1037
1047
  ? createCodexAuthDispatchGuard(retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
@@ -1143,6 +1153,7 @@ export function codexForwardTerminalOutcomeRecorder(
1143
1153
  logCtx?: RequestLogContext,
1144
1154
  ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
1145
1155
  if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
1156
+ const windowMeta = codexFailureWindowOutcomeMeta(authCtx);
1146
1157
  return (status, httpStatusOverride) => {
1147
1158
  const quotaStatus = [httpStatusOverride, logCtx?.terminalHttpStatus]
1148
1159
  .find(value => value === 429 || value === 402);
@@ -1151,6 +1162,7 @@ export function codexForwardTerminalOutcomeRecorder(
1151
1162
  // request. Don't penalize account health; record success to clear any
1152
1163
  // prior soft-avoid so a healthy account isn't stuck avoided.
1153
1164
  recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
1165
+ ...windowMeta,
1154
1166
  threadId: authCtx.affinityKey,
1155
1167
  fixedAccount: authCtx.fixedAccount,
1156
1168
  modelId,
@@ -1174,6 +1186,7 @@ export function codexForwardTerminalOutcomeRecorder(
1174
1186
  ? 200
1175
1187
  : (quotaStatus ?? httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
1176
1188
  recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1189
+ ...windowMeta,
1177
1190
  threadId: authCtx.affinityKey,
1178
1191
  fixedAccount: authCtx.fixedAccount,
1179
1192
  modelId,
@@ -45,6 +45,7 @@ import {
45
45
  } from "../../combos";
46
46
  import { formatErrorResponse } from "../../bridge";
47
47
  import { SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors";
48
+ import { redactSecretString, sanitizeLogMetadataString } from "../../lib/redact";
48
49
  import {
49
50
  expandPreviousResponseInput,
50
51
  previousResponseReplayFailure,
@@ -98,6 +99,7 @@ import type { CodexAuthContext } from "../../codex/auth-context";
98
99
  import type { ResponsesTerminalStatus } from "../../bridge";
99
100
  import { beginRequestAttempt, sealRequestAttemptIdentity, finishRequestAttempt } from "../request-log";
100
101
  import { rememberComboForLane } from "./combo-session-recall";
102
+ import { COMBO_TOOL_LOOP_PIN_REASON, toolLoopPinnedPick } from "./combo-tool-loop-pin";
101
103
  import { runTurnAdapterSseResponses } from "./core-lifetime";
102
104
  import { normalizePersistedJevDecision } from "../../usage/jev-stats";
103
105
  import {
@@ -589,7 +591,19 @@ export async function executeComboResponses(
589
591
  waitForCooldownMs: combo.waitForCooldownMs,
590
592
  abortSignal: options.abortSignal,
591
593
  });
592
- let pick = await pickWithWait({
594
+ // An in-flight tool loop stays on the target that issued its calls while that target is still
595
+ // selectable (#7298). Override routes never record a lane target, so they never pin either.
596
+ const toolLoopPin = options.compactionRoutingOverride || options.mainHardLockFallbackOverride
597
+ ? undefined
598
+ : toolLoopPinnedPick({
599
+ config,
600
+ comboId,
601
+ lane: sessionLaneIdFromRequest(req.headers),
602
+ body,
603
+ eligible: target => (meteredAllowed || !target.metered) && targetEligible(target),
604
+ now: initialNow,
605
+ });
606
+ let pick = toolLoopPin ?? await pickWithWait({
593
607
  eligible: targetEligible,
594
608
  now: initialNow,
595
609
  });
@@ -625,7 +639,7 @@ export async function executeComboResponses(
625
639
  : comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible));
626
640
  }
627
641
  let jevDecision: JevDecision | undefined;
628
- if (combo.strategy === "jev") {
642
+ if (combo.strategy === "jev" && !toolLoopPin) {
629
643
  const choices = eligibleJevComboChoices(config, comboId, targetEligible, Date.now());
630
644
  const first = choices[0];
631
645
  if (!first) {
@@ -653,6 +667,7 @@ export async function executeComboResponses(
653
667
  isDestinationAllowed: (providerName, modelId) => routeAllowedByScope(
654
668
  resolveAdmissionModelScope(config, options.admission), { providerName, modelId },
655
669
  ),
670
+ ...(combo.decisionQuotaSignals === true ? { decisionQuotaSignals: true, decisionQuotaTiers: combo.decisionQuotaTiers } : {}),
656
671
  ...(combo.decisionProvider ? { decisionProvider: combo.decisionProvider } : {}),
657
672
  ...(combo.decisionModel
658
673
  ? {
@@ -696,6 +711,7 @@ export async function executeComboResponses(
696
711
  ? { chosenProbability: decision.chosenProbability }
697
712
  : {}),
698
713
  ...(decision.usage ? { usage: decision.usage } : {}),
714
+ ...(decision.quota ? { quota: decision.quota } : {}),
699
715
  });
700
716
  console.debug("[combo] JEV decision", {
701
717
  backend: decision.backend,
@@ -713,8 +729,11 @@ export async function executeComboResponses(
713
729
  // One immutable combo selection trace, before any child dispatch; child
714
730
  // adoption below must never replace it with a concrete child route trace.
715
731
  const decision = comboRouteDecisionTrace(config, comboId, pick, requestedModel);
716
- logCtx.routeDecision = options.memoryModelPhase
717
- ? { ...decision, selected: { ...decision.selected, reason: memoryModelRouteReason(options.memoryModelPhase) } }
732
+ const selectedReason = options.memoryModelPhase
733
+ ? memoryModelRouteReason(options.memoryModelPhase)
734
+ : toolLoopPin ? COMBO_TOOL_LOOP_PIN_REASON : undefined;
735
+ logCtx.routeDecision = selectedReason
736
+ ? { ...decision, selected: { ...decision.selected, reason: selectedReason } }
718
737
  : decision;
719
738
 
720
739
  const originalReasoning = body && typeof body === "object" && !Array.isArray(body)
@@ -1146,6 +1165,9 @@ export async function executeComboResponses(
1146
1165
  : comboFailureDecision(failure.response.status, failure.classificationText, {
1147
1166
  code: failure.upstreamCode,
1148
1167
  codexModelRefusal: failure.codexModelRefusal,
1168
+ // The adapter the failed leg actually resolved: a configured `kiro` provider can route a
1169
+ // model through another wire via `modelAdapters`, and an unresolved leg grants no hop.
1170
+ failedAdapter: childLog.providerAdapter,
1149
1171
  });
1150
1172
  const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true;
1151
1173
  // Local byte admission has its own diagnostic; do not relabel it as an upstream refusal.
@@ -1193,8 +1215,15 @@ export async function executeComboResponses(
1193
1215
  }
1194
1216
  return lastFailure;
1195
1217
  }
1218
+ // A bare status made a revoked credential read as a provider outage. Log only the parsed
1219
+ // code and message, never the raw envelope, as one redacted, bounded line.
1220
+ const failureReason = sanitizeLogMetadataString(
1221
+ redactSecretString([failure.upstreamCode, failure.upstreamMessage].filter(Boolean).join(": ")).replace(/\s+/g, " "),
1222
+ 200,
1223
+ );
1196
1224
  console.warn(
1197
- `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${Date.now() - started}ms`,
1225
+ `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status}`
1226
+ + ` after ${Date.now() - started}ms${failureReason ? `: ${failureReason}` : ""}`,
1198
1227
  );
1199
1228
  const failureNow = Date.now();
1200
1229
  const attemptedTargets = pick.attempted;