@bitkyc08/opencodex 2.56.0 → 2.58.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 (206) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
  3. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
  8. package/src/adapters/command-code.ts +12 -3
  9. package/src/adapters/cursor/cursor-errors.ts +15 -0
  10. package/src/adapters/cursor/discovery.ts +65 -1
  11. package/src/adapters/cursor/envelope-echo.ts +8 -2
  12. package/src/adapters/cursor/live-transport.ts +5 -1
  13. package/src/adapters/cursor/protobuf-events.ts +110 -11
  14. package/src/adapters/cursor/protobuf-request.ts +19 -1
  15. package/src/adapters/cursor/text-toolcall.ts +230 -0
  16. package/src/adapters/cursor/thread-continuity.ts +67 -0
  17. package/src/adapters/cursor/types.ts +5 -0
  18. package/src/adapters/cursor.ts +55 -5
  19. package/src/adapters/google-http.ts +38 -13
  20. package/src/adapters/google.ts +7 -7
  21. package/src/adapters/kiro/payload.ts +17 -3
  22. package/src/adapters/kiro/reasoning.ts +70 -7
  23. package/src/adapters/kiro/stream.ts +8 -2
  24. package/src/adapters/kiro/wire.ts +2 -1
  25. package/src/adapters/kiro-events.ts +21 -13
  26. package/src/adapters/mimo-free.ts +32 -17
  27. package/src/adapters/ollama-native.ts +42 -8
  28. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  29. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  30. package/src/adapters/openai-chat.ts +8 -8
  31. package/src/adapters/openai-responses/passthrough.ts +62 -5
  32. package/src/adapters/openai-responses/request-strips.ts +43 -0
  33. package/src/adapters/physical-send.ts +50 -0
  34. package/src/bridge/errors.ts +26 -2
  35. package/src/bridge/response-json.ts +8 -2
  36. package/src/bridge/sse.ts +20 -2
  37. package/src/claude/desktop-profile.ts +66 -9
  38. package/src/claude/outbound.ts +32 -4
  39. package/src/cli/account-main.ts +1 -1
  40. package/src/cli/capabilities.ts +2 -2
  41. package/src/cli/combo.ts +10 -1
  42. package/src/cli/config-command.ts +35 -18
  43. package/src/cli/dispatch.ts +17 -4
  44. package/src/cli/index.ts +92 -7
  45. package/src/cli/registry.ts +2 -1
  46. package/src/cli/system-command.ts +74 -5
  47. package/src/cli/uninstall-client-state.ts +12 -0
  48. package/src/clients/config-export.ts +7 -3
  49. package/src/codex/account-label.ts +14 -3
  50. package/src/codex/account-store.ts +113 -26
  51. package/src/codex/account-usability.ts +21 -0
  52. package/src/codex/auth-api/login-flow.ts +14 -2
  53. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  54. package/src/codex/auth-context.ts +199 -15
  55. package/src/codex/catalog/aggregation.ts +80 -1
  56. package/src/codex/catalog/model-visibility.ts +1 -0
  57. package/src/codex/catalog/remote.ts +30 -0
  58. package/src/codex/catalog/retained-sync.ts +9 -1
  59. package/src/codex/catalog/routed-gather.ts +38 -1
  60. package/src/codex/cli-install-provenance.ts +7 -1
  61. package/src/codex/convergence.ts +7 -2
  62. package/src/codex/desktop-app/types.ts +11 -2
  63. package/src/codex/desktop-app/windows.ts +5 -5
  64. package/src/codex/desktop-switches.ts +145 -0
  65. package/src/codex/history-job.ts +5 -1
  66. package/src/codex/history-provider.ts +33 -4
  67. package/src/codex/history-worker.ts +14 -1
  68. package/src/codex/inject/remove.ts +145 -7
  69. package/src/codex/inject/restore.ts +231 -32
  70. package/src/codex/inject.ts +12 -16
  71. package/src/codex/loopback-target.ts +9 -0
  72. package/src/codex/model-entitlements.ts +152 -15
  73. package/src/codex/native-profile-startup.ts +64 -20
  74. package/src/codex/pool-refresh-backoff.ts +12 -3
  75. package/src/codex/quota-rejection.ts +104 -15
  76. package/src/codex/routing/cache-affinity.ts +70 -0
  77. package/src/codex/routing/cooldown-math.ts +10 -0
  78. package/src/codex/routing/selection.ts +79 -2
  79. package/src/codex/routing/thread-affinity.ts +50 -2
  80. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  81. package/src/codex/routing.ts +29 -49
  82. package/src/codex/warmup.ts +1 -1
  83. package/src/combos/failover.ts +85 -0
  84. package/src/combos/request.ts +17 -10
  85. package/src/combos/types.ts +23 -2
  86. package/src/config/atomic-write.ts +83 -8
  87. package/src/config/pending-teardown.ts +31 -0
  88. package/src/config/schema/config-schema.ts +2 -0
  89. package/src/config/schema/leaf-validators.ts +1 -0
  90. package/src/generated/compatibility-version.json +272 -180
  91. package/src/images/loop.ts +1 -1
  92. package/src/lib/bounded-subprocess.ts +62 -10
  93. package/src/lib/errors.ts +17 -0
  94. package/src/lib/request-execution-budget.ts +147 -21
  95. package/src/lib/spend-reservation-ledger.ts +18 -0
  96. package/src/lib/state-store-registrations.ts +6 -2
  97. package/src/lib/test-home-guard.ts +85 -1
  98. package/src/lib/upstream-retry.ts +77 -10
  99. package/src/lib/windows-elevation.ts +76 -14
  100. package/src/lib/windows-secret-acl.ts +151 -15
  101. package/src/lib/windows-user-principal.ts +5 -1
  102. package/src/oauth/index.ts +2 -2
  103. package/src/oauth/key-providers.ts +2 -2
  104. package/src/providers/derive.ts +6 -0
  105. package/src/providers/kiro-models.ts +4 -3
  106. package/src/providers/label.ts +19 -1
  107. package/src/providers/model-discovery.ts +35 -7
  108. package/src/providers/registry/entries-core.ts +18 -0
  109. package/src/providers/registry/entries-extended.ts +59 -28
  110. package/src/providers/registry/model-seeds.ts +71 -17
  111. package/src/providers/registry/types.ts +9 -0
  112. package/src/responses/reasoning-envelope.ts +6 -3
  113. package/src/responses/spill-store.ts +17 -0
  114. package/src/responses/state/body-policy.ts +25 -0
  115. package/src/responses/state/spill-queue.ts +8 -6
  116. package/src/responses/state.ts +3 -22
  117. package/src/router.ts +4 -0
  118. package/src/routing/identity-domains.ts +21 -14
  119. package/src/routing/probe-lease.ts +103 -1
  120. package/src/server/auth-cors.ts +1 -0
  121. package/src/server/chat-completions.ts +3 -1
  122. package/src/server/chat-native.ts +37 -9
  123. package/src/server/index/live-sideband.ts +37 -1
  124. package/src/server/index/websocket-handler.ts +54 -3
  125. package/src/server/index.ts +5 -5
  126. package/src/server/inspection-tee.ts +107 -0
  127. package/src/server/live.ts +46 -1
  128. package/src/server/management/combo-routes.ts +10 -1
  129. package/src/server/management/config-routes.ts +27 -5
  130. package/src/server/models-capabilities.ts +24 -3
  131. package/src/server/relay-eager.ts +2 -0
  132. package/src/server/relay.ts +14 -19
  133. package/src/server/request-log.ts +127 -3
  134. package/src/server/response-log-body.ts +153 -0
  135. package/src/server/responses/account-change-state.ts +74 -0
  136. package/src/server/responses/adapter-continuation.ts +33 -7
  137. package/src/server/responses/adapter-delivery.ts +5 -11
  138. package/src/server/responses/adapter-dispatch.ts +84 -13
  139. package/src/server/responses/codex-ws-exchange.ts +65 -4
  140. package/src/server/responses/codex-ws-wire.ts +5 -0
  141. package/src/server/responses/collaboration.ts +74 -4
  142. package/src/server/responses/combo-session-recall.ts +68 -8
  143. package/src/server/responses/combo-stream-preflight.ts +68 -5
  144. package/src/server/responses/compact.ts +54 -13
  145. package/src/server/responses/core-auth.ts +2 -0
  146. package/src/server/responses/core-codex-account.ts +51 -3
  147. package/src/server/responses/core-combo.ts +129 -23
  148. package/src/server/responses/core-errors.ts +18 -0
  149. package/src/server/responses/core-options.ts +3 -0
  150. package/src/server/responses/core-replay.ts +105 -32
  151. package/src/server/responses/core.ts +3 -3
  152. package/src/server/responses/encrypted-payload.ts +0 -1
  153. package/src/server/responses/fetch-helpers.ts +4 -1
  154. package/src/server/responses/input-admission.ts +126 -6
  155. package/src/server/responses/native-injection-protocol.ts +42 -0
  156. package/src/server/responses/native-injection-replay.ts +105 -0
  157. package/src/server/responses/native-injection.ts +242 -0
  158. package/src/server/responses/native-response-control.ts +56 -0
  159. package/src/server/responses/native-response-json.ts +14 -0
  160. package/src/server/responses/native-response-output.ts +37 -0
  161. package/src/server/responses/native-steering-log.ts +44 -0
  162. package/src/server/responses/native-steering-policy.ts +49 -0
  163. package/src/server/responses/native-steering-replay.ts +126 -0
  164. package/src/server/responses/native-steering-settings.ts +76 -0
  165. package/src/server/responses/native-steering.ts +400 -0
  166. package/src/server/responses/native-tool-results.ts +130 -0
  167. package/src/server/responses/passthrough-delivery.ts +30 -6
  168. package/src/server/responses/passthrough-dispatch.ts +61 -11
  169. package/src/server/responses/passthrough-error.ts +38 -2
  170. package/src/server/responses/request-prepare.ts +173 -22
  171. package/src/server/responses/request-send-budget.ts +97 -2
  172. package/src/server/responses/request-spend.ts +147 -0
  173. package/src/server/responses/request-transport.ts +62 -3
  174. package/src/server/responses/run-turn-execution.ts +59 -31
  175. package/src/server/responses/sidecar-execution.ts +7 -13
  176. package/src/server/responses/terminal-guard.ts +65 -4
  177. package/src/server/responses/ws-upstream.ts +21 -1
  178. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  179. package/src/server/stop-teardown.ts +8 -1
  180. package/src/server/ws-bridge.ts +16 -1
  181. package/src/service/cli.ts +13 -1
  182. package/src/service/windows-ops.ts +210 -16
  183. package/src/service/windows-scheduler.ts +28 -21
  184. package/src/service.ts +1 -1
  185. package/src/types/config.ts +8 -1
  186. package/src/types/provider.ts +13 -0
  187. package/src/types/request.ts +8 -5
  188. package/src/types/tools.ts +24 -0
  189. package/src/types.ts +2 -0
  190. package/src/update/index.ts +10 -0
  191. package/src/update/stop-contract.d.mts +1 -0
  192. package/src/update/stop-contract.mjs +19 -0
  193. package/src/update/stop-decision.d.mts +1 -1
  194. package/src/update/stop-decision.mjs +12 -3
  195. package/src/usage/log.ts +1 -1
  196. package/src/vision/anthropic-describe.ts +1 -1
  197. package/src/vision/describe.ts +5 -5
  198. package/src/web-search/anthropic-executor.ts +1 -1
  199. package/src/web-search/exa-executor.ts +1 -1
  200. package/src/web-search/executor.ts +1 -1
  201. package/src/web-search/gemini-executor.ts +1 -1
  202. package/src/web-search/loop.ts +1 -1
  203. package/src/web-search/ollama-executor.ts +1 -1
  204. package/src/web-search/parse.ts +67 -14
  205. package/src/web-search/passthrough-bridge.ts +64 -31
  206. package/src/web-search/xai-executor.ts +1 -1
@@ -1,6 +1,8 @@
1
+ import { isDeclaredReasoningEffort } from "../../reasoning-effort";
2
+ import { recordAttemptRequestedEffort } from "../request-log";
1
3
  import {
2
4
  CODEX_TEXT_GUARDED_BUDGET_POLICY,
3
- createRequestExecutionBudget,
5
+ deriveRequestExecutionBudget,
4
6
  isRequestExecutionBudget,
5
7
  } from "../../lib/request-execution-budget";
6
8
  import type {
@@ -44,6 +46,7 @@ import { isThreadSpawnRequest, supportedLadderFor } from "../effort-policy";
44
46
  import {
45
47
  clientCancelledResponse,
46
48
  comboUnavailable,
49
+ targetIncompatibleResponse,
47
50
  unreadableEncryptedAgentTaskResponse,
48
51
  } from "./core-errors";
49
52
  import {
@@ -51,7 +54,12 @@ import {
51
54
  createChildPassthroughCallbackGate,
52
55
  consumeComboFailure,
53
56
  } from "./core-combo-failure";
54
- import { linkRequestSessionLane, sessionLaneIdFromRequest } from "../request-log-conversation";
57
+ import {
58
+ linkRequestSessionLane,
59
+ reasoningReplayConversationIdFromResponsesRequest,
60
+ sessionIdHeaderFromRequest,
61
+ sessionLaneIdFromRequest,
62
+ } from "../request-log-conversation";
55
63
  import type { CodexAuthContext } from "../../codex/auth-context";
56
64
  import type { ResponsesTerminalStatus } from "../../bridge";
57
65
  import { beginRequestAttempt, sealRequestAttemptIdentity, finishRequestAttempt } from "../request-log";
@@ -65,6 +73,7 @@ import {
65
73
  } from "../relay";
66
74
  import { preflightComboStreamResponse } from "./combo-stream-preflight";
67
75
  import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
76
+ import { mandatoryResponsesReasoningReplayUnavailable } from "./core-replay";
68
77
 
69
78
  /**
70
79
  * Sends one combo target may run on its own before the ladder moves on. A target is a whole
@@ -105,25 +114,27 @@ export function comboExecutionBudgetPolicy(declaredTargets: number): RequestExec
105
114
  /**
106
115
  * A budget scope that keeps its own recovery ledgers but spends the SAME request-wide counter.
107
116
  *
108
- * `used` is redefined as an accessor onto the parent because the factory reads it back off this
109
- * object -- `remainingBaseSends` and the total check both do -- so a copied number would let a
110
- * combo target run its ladder against a stale total, which is precisely the per-layer counting
111
- * this work exists to remove. The reserve, alternate-target and transition ledgers stay
112
- * per-scope on purpose: a combo target's account failover is its own recovery decision, while
113
- * the request total still bounds every target together.
117
+ * The sharing has to happen inside the factory. Redefining `used` as an accessor onto the parent
118
+ * only shared what callers read from the outside: `remainingBaseSends`, the total check and the
119
+ * reserve test all consult the factory's own private counter, which an overridden property
120
+ * cannot reach. Each derived scope therefore admitted dispatches as though the request had spent
121
+ * nothing, and the per-target holdback below -- expressed against `maxTotalModelSends` -- had
122
+ * nothing to hold back from.
123
+ *
124
+ * `deriveRequestExecutionBudget` binds the scope to the parent's real ledger, including pending
125
+ * externally-counted bookings and the durable-spend observer, all of which must travel together.
126
+ * A pending booking is a send already counted in the total and waiting for its reporter, and the
127
+ * observer books by watching that same counter move (#4707) -- so a scope that spent the counter
128
+ * without carrying the observer would move it without booking, and this combo's child sends
129
+ * would go missing from the spend ledger. The reserve, alternate-target and transition ledgers
130
+ * stay per-scope on purpose: a combo target's account failover is its own recovery decision,
131
+ * while the request total still bounds every target together.
114
132
  */
115
133
  export function deriveSendBudgetScope(
116
134
  parent: RequestExecutionBudget,
117
135
  policy: RequestExecutionBudgetPolicy,
118
136
  ): RequestExecutionBudget {
119
- const scope = createRequestExecutionBudget(policy, parent.logicalRequestId);
120
- Object.defineProperty(scope, "used", {
121
- get: () => parent.used,
122
- set: (value: number) => { parent.used = value; },
123
- enumerable: true,
124
- configurable: true,
125
- });
126
- return scope;
137
+ return deriveRequestExecutionBudget(parent, policy);
127
138
  }
128
139
 
129
140
 
@@ -229,6 +240,29 @@ export async function executeComboResponses(
229
240
  : undefined,
230
241
  recoveredPlaintext: false,
231
242
  };
243
+ const reasoningReplayConversationId = reasoningReplayConversationIdFromResponsesRequest({
244
+ clientThreadId: inboundClientThreadId,
245
+ threadIdHeader: req.headers.get("thread-id"),
246
+ sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
247
+ });
248
+ const reasoningReplayEligible = (target: (typeof combo.targets)[number]): boolean => {
249
+ try {
250
+ const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
251
+ const unavailable = mandatoryResponsesReasoningReplayUnavailable({
252
+ body,
253
+ clientThreadId: reasoningReplayConversationId,
254
+ providerName: route.providerName,
255
+ provider: route.provider,
256
+ adapterName: route.provider.adapter,
257
+ modelId: route.modelId,
258
+ });
259
+ return !unavailable;
260
+ } catch {
261
+ // Routing failures are not evidence of replay incompatibility. Keep the target eligible so
262
+ // the existing selection and dispatch path preserves its original routing failure surface.
263
+ return true;
264
+ }
265
+ };
232
266
  const adoptFailedChildLog = (childLog: RequestLogContext): void => {
233
267
  // Attempts remain the complete physical history; the logical row mirrors the most recent
234
268
  // failed target so an exhausted combo still has useful top-level reasoning diagnostics.
@@ -260,6 +294,18 @@ export async function executeComboResponses(
260
294
  let comboPayloadReadable = false;
261
295
  const payloadEligible = (target: (typeof combo.targets)[number]): boolean =>
262
296
  comboPayloadReadable || !unreadableEncryptedAgentTask || canDecryptUnreadableAgentTask(target);
297
+ const targetEligible = (target: (typeof combo.targets)[number]): boolean =>
298
+ payloadEligible(target) && reasoningReplayEligible(target);
299
+ const onlyReplayIncompatibleTargetsRemain = (excluded: Iterable<string> = []): boolean => {
300
+ const excludedKeys = new Set(excluded);
301
+ const remaining = combo.targets.filter(target => {
302
+ const provider = config.providers[target.provider];
303
+ return provider?.disabled !== true
304
+ && !excludedKeys.has(targetKey(target))
305
+ && payloadEligible(target);
306
+ });
307
+ return remaining.length > 0 && remaining.every(target => !reasoningReplayEligible(target));
308
+ };
263
309
  let encryptedTaskRecoveryAttempted = false;
264
310
  let recoveryFailureReason: AgentTaskRecoveryFailureReason | undefined;
265
311
  let storedPool401ReplayDispatched = false;
@@ -324,7 +370,7 @@ export async function executeComboResponses(
324
370
  abortSignal: options.abortSignal,
325
371
  });
326
372
  let pick = await pickWithWait({
327
- eligible: payloadEligible,
373
+ eligible: targetEligible,
328
374
  now: initialNow,
329
375
  });
330
376
 
@@ -349,6 +395,7 @@ export async function executeComboResponses(
349
395
  }
350
396
 
351
397
  if (!pick) {
398
+ if (onlyReplayIncompatibleTargetsRemain()) return targetIncompatibleResponse();
352
399
  return options.abortSignal?.aborted
353
400
  ? clientCancelledResponse()
354
401
  : comboUnavailable(comboId);
@@ -357,6 +404,26 @@ export async function executeComboResponses(
357
404
  // adoption below must never replace it with a concrete child route trace.
358
405
  logCtx.routeDecision = comboRouteDecisionTrace(config, comboId, pick, requestedModel);
359
406
 
407
+ const originalReasoning = body && typeof body === "object" && !Array.isArray(body)
408
+ ? (body as { reasoning?: unknown }).reasoning
409
+ : undefined;
410
+ const originalRequestedEffortValue = originalReasoning && typeof originalReasoning === "object" && !Array.isArray(originalReasoning)
411
+ ? (originalReasoning as { effort?: unknown }).effort
412
+ : undefined;
413
+ const originalRequestedEffort = typeof originalRequestedEffortValue === "string"
414
+ && isDeclaredReasoningEffort(originalRequestedEffortValue)
415
+ ? originalRequestedEffortValue
416
+ : undefined;
417
+ const restoreOriginalRequestedEffort = (childLog: RequestLogContext): void => {
418
+ if (originalRequestedEffort === undefined) return;
419
+ const normalizedRequestedEffort = childLog.requestedEffort;
420
+ const transitionIndex = normalizedRequestedEffort?.indexOf("->") ?? -1;
421
+ childLog.requestedEffort = transitionIndex >= 0
422
+ ? `${originalRequestedEffort}${normalizedRequestedEffort!.slice(transitionIndex)}`
423
+ : originalRequestedEffort;
424
+ recordAttemptRequestedEffort(childLog);
425
+ };
426
+
360
427
  let lastFailure: Response | null = null;
361
428
  // Dispatched targets, not attempted picks: it indexes the declared target list so the clamp
362
429
  // below can tell how many targets are still entitled to a send.
@@ -407,6 +474,7 @@ export async function executeComboResponses(
407
474
  comboDefaultEffort(config, comboId),
408
475
  supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
409
476
  combo.reasoningEffortMode,
477
+ combo.defaultEffortMode,
410
478
  );
411
479
  const childHeaders = buildComboChildHeaders(req.headers);
412
480
  const childRequest = new Request(req.url, {
@@ -425,6 +493,13 @@ export async function executeComboResponses(
425
493
  config.providers[pick.target.provider]!.adapter,
426
494
  );
427
495
  childLog.activeAttempt = attempt;
496
+ if (originalRequestedEffort !== undefined) {
497
+ childLog.requestedEffort = originalRequestedEffort;
498
+ recordAttemptRequestedEffort(childLog);
499
+ }
500
+ childLog.activeAttemptStartedAt = started;
501
+ childLog.attempts = logCtx.attempts ??= [];
502
+ childLog.attempts.push(attempt);
428
503
  let attemptRetained = false;
429
504
  const retainCancelledAttempt = (): void => {
430
505
  if (attemptRetained) return;
@@ -435,7 +510,6 @@ export async function executeComboResponses(
435
510
  childLog.accountLogLabel,
436
511
  );
437
512
  finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage);
438
- (logCtx.attempts ??= []).push(attempt);
439
513
  attemptRetained = true;
440
514
  };
441
515
  const completedTarget = { provider: pick.target.provider, model: pick.target.model };
@@ -472,7 +546,7 @@ export async function executeComboResponses(
472
546
  const deferCodexResetDerivedCooldown = combo.strategy === "failover"
473
547
  && combo.targets.slice(pick.targetIndex + 1).some(target =>
474
548
  target.provider === currentTargetProvider
475
- && payloadEligible(target)
549
+ && targetEligible(target)
476
550
  && !isComboTargetInCooldown(comboId, target),
477
551
  );
478
552
  response = await requestDispatchers.handleResponses(childRequest, config, childLog, {
@@ -499,12 +573,14 @@ export async function executeComboResponses(
499
573
  onNativePassthroughCancel: callbackGate.onCancel,
500
574
  onResponseComplete: callbackGate.onResponseComplete,
501
575
  });
576
+ restoreOriginalRequestedEffort(childLog);
502
577
  } catch (error) {
503
578
  callbackGate.discard();
504
579
  if (options.abortSignal?.aborted) {
505
580
  retainCancelledAttempt();
506
581
  return clientCancelledResponse();
507
582
  }
583
+ finishRequestAttempt(attempt, 502, Date.now() - started, childLog.usage);
508
584
  throw error;
509
585
  }
510
586
 
@@ -526,6 +602,7 @@ export async function executeComboResponses(
526
602
  retainCancelledAttempt();
527
603
  return clientCancelledResponse();
528
604
  }
605
+ finishRequestAttempt(attempt, 502, Date.now() - started, childLog.usage);
529
606
  throw error;
530
607
  }
531
608
  if (preflight.kind === "failed") {
@@ -546,7 +623,6 @@ export async function executeComboResponses(
546
623
  childLog.providerAdapter ?? attempt.adapter,
547
624
  childLog.accountLogLabel,
548
625
  );
549
- (logCtx.attempts ??= []).push(attempt);
550
626
  attemptRetained = true;
551
627
  noteComboSuccess(comboId, combo, pick.target, pick.writerGeneration);
552
628
  Object.assign(logCtx, childLog, {
@@ -580,6 +656,7 @@ export async function executeComboResponses(
580
656
  retainCancelledAttempt();
581
657
  return clientCancelledResponse();
582
658
  }
659
+ finishRequestAttempt(attempt, 502, Date.now() - started, childLog.usage);
583
660
  throw error;
584
661
  }
585
662
  if (options.abortSignal?.aborted) {
@@ -598,7 +675,6 @@ export async function executeComboResponses(
598
675
  Date.now() - started,
599
676
  failure.usage,
600
677
  );
601
- (logCtx.attempts ??= []).push(attempt);
602
678
  attemptRetained = true;
603
679
  lastFailure = failure.response;
604
680
  lastFailedChildLog = childLog;
@@ -664,22 +740,52 @@ export async function executeComboResponses(
664
740
  cooldownScope: comboFailureCooldownScope(failure.response.status, failure.classificationText, {
665
741
  code: failure.upstreamCode,
666
742
  }),
667
- eligible: payloadEligible,
743
+ eligible: targetEligible,
668
744
  status: failure.response.status,
669
745
  code: failure.upstreamCode,
670
746
  message: failure.classificationText,
671
747
  });
748
+ // Same target selector as the exclusionary pick below, minus `exclude`: the only
749
+ // difference is deliberate and is the whole point of the single-target retry.
750
+ const retryAfterCooldown = () =>
751
+ pickWithWait({
752
+ eligible: targetEligible,
753
+ now: failureNow,
754
+ });
672
755
  if (nextPick) {
673
756
  pick = nextPick;
674
757
  } else {
675
758
  pick = await pickWithWait({
676
759
  exclude: pick.attempted,
677
- eligible: payloadEligible,
760
+ eligible: targetEligible,
678
761
  now: failureNow,
679
762
  });
763
+ // A single-target combo with waitForCooldownMs has no alternate target to fail over to,
764
+ // but can recover if it waits for its brief cooldown. The initial attempt accumulated into
765
+ // pick.attempted, so the first pickWithWait above excluded it. retryAfterCooldown below is
766
+ // the same selector with `exclude` deliberately dropped, so the single cooled target
767
+ // becomes eligible again once its cooldown expires.
768
+ // Termination is double-guarded:
769
+ // 1) comboSendScope?.reserveDispatch refuses a second failover hop via comboExecutionBudgetPolicy
770
+ // (maxAlternateTargetSends: 1 for a single declared target).
771
+ // 2) comboTargetsDispatched <= 1 bounds it locally so the retry never loops or waits unnecessarily
772
+ // even if sendBudget scope is absent.
773
+ if (
774
+ !pick
775
+ && combo.targets.length === 1
776
+ && combo.waitForCooldownMs > 0
777
+ && comboTargetsDispatched <= 1
778
+ && !options.abortSignal?.aborted
779
+ ) {
780
+ pick = await retryAfterCooldown();
781
+ }
680
782
  }
681
783
  if (!pick) {
682
784
  if (options.abortSignal?.aborted) return clientCancelledResponse();
785
+ if (onlyReplayIncompatibleTargetsRemain(attemptedTargets)) {
786
+ adoptFailedChildLog(childLog);
787
+ return targetIncompatibleResponse();
788
+ }
683
789
  if (unreadableEncryptedAgentTask && !comboPayloadReadable) {
684
790
  const recoveredTarget = await pickWithWait({
685
791
  exclude: attemptedTargets,
@@ -150,3 +150,21 @@ export function unreadableEncryptedAgentTaskResponse(reason?: AgentTaskRecoveryF
150
150
  { status: 400, headers: { "Content-Type": "application/json" } },
151
151
  );
152
152
  }
153
+
154
+
155
+ export const TARGET_INCOMPATIBLE_MESSAGE =
156
+ "No remaining combo target can continue this tool-bearing history because the reasoning required for replay is unavailable after the serving route changed. Start a new conversation or configure a combo target that can consume the available reasoning.";
157
+
158
+
159
+ export function targetIncompatibleResponse(): Response {
160
+ return new Response(
161
+ JSON.stringify({
162
+ error: {
163
+ message: TARGET_INCOMPATIBLE_MESSAGE,
164
+ type: "invalid_request_error",
165
+ code: "target_incompatible",
166
+ },
167
+ }),
168
+ { status: 400, headers: { "Content-Type": "application/json" } },
169
+ );
170
+ }
@@ -1,3 +1,4 @@
1
+ import type { NativeResponseControl } from "./native-response-control";
1
2
  import type { OcxUsage, OcxProviderContinuationState, OcxConfig } from "../../types";
2
3
  import type { CodexAuthPolicyConfig, CodexAuthContext } from "../../codex/auth-context";
3
4
  import type { AdmissionLease } from "../../lib/admission";
@@ -51,6 +52,8 @@ export interface HandleResponsesOptions {
51
52
  /** Called at most once after the complete client body is read and accepted for dispatch. */
52
53
  onRequestBodyRead?: () => void;
53
54
  forceEmptyResponseId?: boolean;
55
+ /** Internal, connection-owned control channel; never reconstructed from headers. */
56
+ nativeControl?: NativeResponseControl;
54
57
  abortSignal?: AbortSignal;
55
58
  /** One-shot TTFT callback: first non-empty model output observed (WP4). */
56
59
  onFirstOutput?: () => void;
@@ -27,6 +27,7 @@ import type { OAuthAccessSnapshot } from "../../oauth";
27
27
  import type { CodexAuthContext } from "../../codex/auth-context";
28
28
  import { thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay";
29
29
  import { randomUUID } from "node:crypto";
30
+ import { requiresPlaintextReasoningReplay } from "../../adapters/openai-responses/passthrough";
30
31
 
31
32
  /**
32
33
  * Adapters whose continuation state must survive Codex's store:false requests.
@@ -107,6 +108,53 @@ export function bindRouteReasoningReplayScope(args: {
107
108
  forwardHeaders?: Headers;
108
109
  }): void {
109
110
  const { parsed, providerName, provider, adapterName } = args;
111
+ const replayIdentity = routeReasoningReplayIdentity({
112
+ ...args,
113
+ modelId: parsed.modelId,
114
+ });
115
+ const continuationDestinationIdentity = providerContinuationDestinationIdentity(parsed, provider);
116
+ const continuationOwner = providerContinuationOwnerFromReplayIdentity(
117
+ replayIdentity && continuationDestinationIdentity
118
+ ? { ...replayIdentity, providerDestinationIdentity: continuationDestinationIdentity }
119
+ : undefined,
120
+ );
121
+ if (adapterName === "cursor") {
122
+ // The final route owner is authoritative for Cursor and supersedes the account-derived
123
+ // seed assigned before route binding. A Cursor conversation must be scoped to the exact
124
+ // provider/destination/adapter/model/credential that serves it.
125
+ if (continuationOwner) parsed._cursorIdentityScope = providerContinuationRouteScope(continuationOwner);
126
+ else if (!parsed._cursorIdentityScope?.startsWith("cursor-unowned:")) {
127
+ // Prevent the adapter's token-only fallback from recreating a provider-private id after the
128
+ // route owner failed closed. The sentinel is per parsed request and contains no credential.
129
+ parsed._cursorIdentityScope = `cursor-unowned:${randomUUID()}`;
130
+ }
131
+ }
132
+ bindReasoningReplayScope(
133
+ parsed._reasoningReplayScope,
134
+ replayIdentity,
135
+ );
136
+ // Keep this sticky for the whole outbound request: a later auth/key rebind may compare equal
137
+ // after the first mismatch, but it cannot make history minted by the prior route decodable.
138
+ if (reasoningReplayServingIdentityChanged(parsed._reasoningReplayScope)) {
139
+ parsed._stripReasoningEncryptedContent = true;
140
+ }
141
+ if (reasoningReplayOpaqueBlobRejectionMemoized(parsed._reasoningReplayScope)) {
142
+ parsed._stripReasoningEncryptedContent = true;
143
+ }
144
+ bindProviderContinuationForRoute(parsed, continuationOwner);
145
+ }
146
+
147
+
148
+ function routeReasoningReplayIdentity(args: {
149
+ providerName: string;
150
+ provider: OcxProviderConfig;
151
+ adapterName: string;
152
+ modelId: string;
153
+ oauthCredentialSnapshot?: Pick<OAuthAccessSnapshot, "accountId" | "generation">;
154
+ codexAuthContext?: CodexAuthContext;
155
+ forwardHeaders?: Headers;
156
+ }): OcxReasoningReplayIdentity | undefined {
157
+ const { providerName, provider, adapterName, modelId } = args;
110
158
  let credentialIdentity: string | undefined;
111
159
  let credentialDurableIdentity: string | undefined;
112
160
  const durableSalt = thoughtSignatureReplaySalt();
@@ -165,47 +213,72 @@ export function bindRouteReasoningReplayScope(args: {
165
213
  );
166
214
  }
167
215
  const providerDestinationIdentity = reasoningReplayDestinationIdentity(provider.baseUrl);
168
- const replayIdentity: OcxReasoningReplayIdentity | undefined = credentialIdentity && providerDestinationIdentity
216
+ return credentialIdentity && providerDestinationIdentity
169
217
  ? {
170
218
  providerName,
171
219
  providerDestinationIdentity,
172
220
  providerDestinationDurableIdentity: durableReplayDestinationIdentity(provider.baseUrl),
173
221
  adapterName,
174
- modelId: parsed.modelId,
222
+ modelId,
175
223
  credentialIdentity,
176
224
  ...(credentialDurableIdentity ? { credentialDurableIdentity } : {}),
177
225
  }
178
226
  : undefined;
179
- const continuationDestinationIdentity = providerContinuationDestinationIdentity(parsed, provider);
180
- const continuationOwner = providerContinuationOwnerFromReplayIdentity(
181
- replayIdentity && continuationDestinationIdentity
182
- ? { ...replayIdentity, providerDestinationIdentity: continuationDestinationIdentity }
183
- : undefined,
184
- );
185
- if (adapterName === "cursor") {
186
- // The final route owner is authoritative for Cursor and supersedes the account-derived
187
- // seed assigned before route binding. A Cursor conversation must be scoped to the exact
188
- // provider/destination/adapter/model/credential that serves it.
189
- if (continuationOwner) parsed._cursorIdentityScope = providerContinuationRouteScope(continuationOwner);
190
- else if (!parsed._cursorIdentityScope?.startsWith("cursor-unowned:")) {
191
- // Prevent the adapter's token-only fallback from recreating a provider-private id after the
192
- // route owner failed closed. The sentinel is per parsed request and contains no credential.
193
- parsed._cursorIdentityScope = `cursor-unowned:${randomUUID()}`;
194
- }
195
- }
196
- bindReasoningReplayScope(
197
- parsed._reasoningReplayScope,
198
- replayIdentity,
199
- );
200
- // Keep this sticky for the whole outbound request: a later auth/key rebind may compare equal
201
- // after the first mismatch, but it cannot make history minted by the prior route decodable.
202
- if (reasoningReplayServingIdentityChanged(parsed._reasoningReplayScope)) {
203
- parsed._stripReasoningEncryptedContent = true;
204
- }
205
- if (reasoningReplayOpaqueBlobRejectionMemoized(parsed._reasoningReplayScope)) {
206
- parsed._stripReasoningEncryptedContent = true;
207
- }
208
- bindProviderContinuationForRoute(parsed, continuationOwner);
227
+ }
228
+
229
+
230
+ /**
231
+ * Whether this exact route cannot satisfy its documented plaintext reasoning replay contract.
232
+ *
233
+ * A generic plaintext-preserving gateway is not made ineligible. Unknown serving provenance also
234
+ * stays eligible; only a proven route mismatch may suppress a combo candidate.
235
+ */
236
+ export function mandatoryResponsesReasoningReplayUnavailable(args: {
237
+ body: unknown;
238
+ clientThreadId: string | undefined;
239
+ providerName: string;
240
+ provider: OcxProviderConfig;
241
+ adapterName: string;
242
+ modelId: string;
243
+ }): boolean {
244
+ const { body, clientThreadId, provider, adapterName } = args;
245
+ if (
246
+ adapterName !== "openai-responses"
247
+ || !requiresPlaintextReasoningReplay(provider)
248
+ || !clientThreadId
249
+ || !requestCarriesTools(body)
250
+ || !hasOpaqueOnlyReasoningItem(body)
251
+ ) return false;
252
+
253
+ const current = routeReasoningReplayIdentity(args);
254
+ return reasoningReplayServingIdentityChanged({ clientThreadId, current });
255
+ }
256
+
257
+
258
+ function requestCarriesTools(body: unknown): boolean {
259
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
260
+ return Array.isArray((body as { tools?: unknown }).tools)
261
+ && (body as { tools: unknown[] }).tools.length > 0;
262
+ }
263
+
264
+
265
+ function hasOpaqueOnlyReasoningItem(body: unknown): boolean {
266
+ if (!body || typeof body !== "object" || Array.isArray(body)) return false;
267
+ const input = (body as { input?: unknown }).input;
268
+ if (!Array.isArray(input)) return false;
269
+ return input.some(item => {
270
+ if (!item || typeof item !== "object" || Array.isArray(item)) return false;
271
+ const reasoning = item as Record<string, unknown>;
272
+ if (reasoning.type !== "reasoning" || typeof reasoning.encrypted_content !== "string") return false;
273
+ const content = reasoning.content;
274
+ const hasPlaintext = Array.isArray(content) && content.some(part =>
275
+ !!part && typeof part === "object" && !Array.isArray(part)
276
+ && (part as Record<string, unknown>).type === "reasoning_text"
277
+ && typeof (part as Record<string, unknown>).text === "string"
278
+ && ((part as Record<string, unknown>).text as string).length > 0
279
+ );
280
+ return !hasPlaintext;
281
+ });
209
282
  }
210
283
 
211
284
 
@@ -10,6 +10,7 @@ import { createTranslatorBudget } from "../../lib/translator-budget";
10
10
  import { captureExplicitOpenAiCallerAuth } from "../../providers/openai-sidecar";
11
11
  import { captureCallerDirectAuth } from "../../providers/caller-authorization";
12
12
  import { createRequestExecutionBudget } from "../../lib/request-execution-budget";
13
+ import { attachRequestSpendTracker } from "./request-spend";
13
14
  import { finalizeOwnedTranslatorBudget } from "./core-lifetime";
14
15
  import type { TranslatorBudget } from "../../lib/translator-budget";
15
16
  import { executeComboResponses } from "./core-combo";
@@ -56,9 +57,8 @@ export async function handleResponses(
56
57
  visionDescribeTerminal: options.visionDescribeTerminal === true
57
58
  || req.headers.get("x-opencodex-vision-describe") === "1",
58
59
  translatorBudget,
59
- // Created once at genuine ingress; a combo child arrives with the parent's holder already
60
- // in options and must not start a fresh allowance.
61
- sendBudget: options.sendBudget ?? createRequestExecutionBudget(),
60
+ // Once at ingress, spend observer included: a combo child inherits the parent's holder.
61
+ sendBudget: options.sendBudget ?? createRequestExecutionBudget(undefined, undefined, attachRequestSpendTracker(req, logCtx)),
62
62
  });
63
63
  return ownsBudget ? finalizeOwnedTranslatorBudget(response, translatorBudget) : response;
64
64
  } catch (error) {
@@ -78,7 +78,6 @@ import {
78
78
  catalogModelSupportsServiceTier,
79
79
  finishRequestAttempt,
80
80
  inspectResponseLogJson,
81
- noteAttemptSend,
82
81
  readConfiguredCodexServiceTier,
83
82
  requestLogSpeedLabel,
84
83
  sealRequestAttemptIdentity,
@@ -1,3 +1,4 @@
1
+ import type { NativeResponseControl } from "./native-response-control";
1
2
  import type { Server } from "bun";
2
3
  import {
3
4
  codexWsUpstreamFetch,
@@ -53,6 +54,7 @@ export interface PaceAwareFetch {
53
54
  export type ProviderFetch = typeof globalThis.fetch & PaceAwareFetch;
54
55
 
55
56
  export interface ProviderFetchOptions {
57
+ nativeControl?: NativeResponseControl;
56
58
  providerName?: string;
57
59
  modelId?: string;
58
60
  /** One pacing slot was acquired immediately before this fetch wrapper was created. */
@@ -101,7 +103,8 @@ export function providerFetch(
101
103
  // used, protocol pin included: a WS turn that falls back is serving the
102
104
  // request over HTTP, and dropping the provider's `upstreamHttpVersion`
103
105
  // there would silently negotiate a transport the operator ruled out.
104
- return codexWsUpstreamFetch(input, init, httpFetch, runtime, options.onCodexWsQuota, options.beforeDispatch);
106
+ return codexWsUpstreamFetch(input, init, httpFetch, runtime, options.onCodexWsQuota, options.beforeDispatch, options.nativeControl,
107
+ () => waitForPacing(init.signal ?? undefined));
105
108
  }
106
109
  return httpFetch(input, init);
107
110
  };