@bitkyc08/opencodex 2.18.2 → 2.20.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 (131) hide show
  1. package/gui/dist/assets/index-DF_UFrGS.css +1 -0
  2. package/gui/dist/assets/index-DSK3S5HY.js +76 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/client-fingerprint.ts +14 -10
  6. package/src/adapters/google-antigravity-wire.ts +4 -3
  7. package/src/adapters/google.ts +1 -1
  8. package/src/adapters/mimo-free.ts +17 -0
  9. package/src/adapters/openai-chat.ts +367 -32
  10. package/src/adapters/registry.ts +144 -0
  11. package/src/chat/inbound.ts +37 -8
  12. package/src/cli/claude.ts +2 -1
  13. package/src/cli/combo.ts +3 -0
  14. package/src/cli/dispatch.ts +8 -0
  15. package/src/cli/export-command.ts +2 -2
  16. package/src/cli/help.ts +2 -0
  17. package/src/cli/index.ts +3 -2
  18. package/src/cli/lab.ts +135 -1
  19. package/src/cli/minimax.ts +491 -0
  20. package/src/cli/models-runtime.ts +22 -1
  21. package/src/cli/models.ts +67 -2
  22. package/src/cli/opencode.ts +2 -1
  23. package/src/cli/registry.ts +22 -2
  24. package/src/clients/config-export.ts +125 -7
  25. package/src/codex/app-server-processes.ts +57 -2
  26. package/src/codex/app-server-restart-service.ts +232 -0
  27. package/src/codex/catalog/aggregation.ts +10 -1
  28. package/src/codex/catalog/effort.ts +15 -3
  29. package/src/codex/catalog/parsing.ts +3 -1
  30. package/src/codex/catalog/provider-fetch.ts +45 -5
  31. package/src/codex/catalog/sync.ts +74 -4
  32. package/src/codex/convergence.ts +2 -0
  33. package/src/codex/shim.ts +100 -5
  34. package/src/combos/index.ts +1 -0
  35. package/src/combos/request.ts +30 -0
  36. package/src/combos/types.ts +6 -0
  37. package/src/config.ts +52 -0
  38. package/src/generated/compatibility-version.json +239 -87
  39. package/src/images/loop.ts +11 -1
  40. package/src/integrations/registry.ts +7 -0
  41. package/src/lab/conformance/jcs.ts +42 -2
  42. package/src/lab/conformance/negative-controls.ts +6 -2
  43. package/src/lab/conformance/runner.ts +16 -5
  44. package/src/lab/fabric/observe.ts +49 -14
  45. package/src/lab/index.ts +16 -0
  46. package/src/lab/ledger/purge.ts +152 -83
  47. package/src/lab/ledger/store.ts +168 -54
  48. package/src/lab/observe/from-conformance.ts +8 -6
  49. package/src/lab/observe/from-live.ts +8 -2
  50. package/src/lab/paths.ts +23 -0
  51. package/src/lab/public/bundle.ts +217 -0
  52. package/src/lab/public/community-authority.ts +175 -0
  53. package/src/lab/public/community-files.ts +29 -0
  54. package/src/lab/public/community.ts +479 -0
  55. package/src/lab/public/file-safety.ts +155 -0
  56. package/src/lab/public/ids.ts +26 -0
  57. package/src/lab/public/index.ts +16 -0
  58. package/src/lab/public/mutation-lock.ts +424 -0
  59. package/src/lab/public/operator.ts +353 -0
  60. package/src/lab/public/origin-purge.ts +79 -0
  61. package/src/lab/public/origin.ts +203 -0
  62. package/src/lab/public/privacy.ts +143 -0
  63. package/src/lab/public/private-file.ts +261 -0
  64. package/src/lab/public/project.ts +124 -0
  65. package/src/lab/public/purge-test-fault.ts +21 -0
  66. package/src/lab/public/purge.ts +223 -0
  67. package/src/lab/public/registry.ts +44 -0
  68. package/src/lab/public/revocation.ts +252 -0
  69. package/src/lab/public/signature.ts +219 -0
  70. package/src/lab/public/storage.ts +105 -0
  71. package/src/lab/public/strict-json.ts +206 -0
  72. package/src/lab/public/time.ts +26 -0
  73. package/src/lab/public/types.ts +172 -0
  74. package/src/lab/public/validate.ts +391 -0
  75. package/src/lib/codex-restart-contract.ts +120 -0
  76. package/src/lib/errors.ts +27 -0
  77. package/src/lib/lab-activation.ts +109 -47
  78. package/src/lib/lab-live-pinned-sender.ts +16 -5
  79. package/src/lib/pinned-http.ts +70 -16
  80. package/src/lib/self-launch-argv.ts +15 -0
  81. package/src/lib/state-store-registrations.ts +2 -0
  82. package/src/lib/upstream-reachability.ts +4 -0
  83. package/src/lib/windows-elevation.ts +10 -1
  84. package/src/providers/derive.ts +24 -4
  85. package/src/providers/registry.ts +9 -3
  86. package/src/providers/request-pacing.ts +310 -0
  87. package/src/providers/service-tier.ts +143 -0
  88. package/src/providers/static-model-discovery.ts +86 -0
  89. package/src/reasoning-effort.ts +27 -1
  90. package/src/responses/spill-store.ts +20 -1
  91. package/src/responses/state.ts +159 -3
  92. package/src/router.ts +23 -6
  93. package/src/routing/capability.ts +4 -2
  94. package/src/routing/compatibility/behavior.ts +5 -1
  95. package/src/server/adapter-resolve.ts +2 -32
  96. package/src/server/auth-cors.ts +8 -0
  97. package/src/server/chat-completions.ts +74 -34
  98. package/src/server/chat-native-sse.ts +331 -0
  99. package/src/server/chat-native.ts +371 -0
  100. package/src/server/effort-policy.ts +18 -0
  101. package/src/server/management/combo-routes.ts +16 -2
  102. package/src/server/management/config-routes.ts +6 -4
  103. package/src/server/management/context.ts +17 -0
  104. package/src/server/management/lab-routes.ts +181 -19
  105. package/src/server/management/model-routes.ts +76 -2
  106. package/src/server/management/model-rows.ts +8 -0
  107. package/src/server/management/provider-capability-config.ts +48 -0
  108. package/src/server/management/provider-routes.ts +76 -4
  109. package/src/server/management/system-restart.ts +4 -2
  110. package/src/server/management/system-routes.ts +38 -0
  111. package/src/server/relay.ts +17 -3
  112. package/src/server/responses/compact.ts +4 -1
  113. package/src/server/responses/core.ts +278 -48
  114. package/src/server/responses/empty-completion-guard.ts +276 -0
  115. package/src/server/responses/fetch-helpers.ts +35 -4
  116. package/src/server/responses/input-admission.ts +169 -0
  117. package/src/server/responses/pacing-overload.ts +13 -0
  118. package/src/server/responses/policy-fallback.ts +16 -2
  119. package/src/server/responses/terminal-guard.ts +1 -1
  120. package/src/server/responses/upstream-error.ts +5 -0
  121. package/src/server/responses.ts +17 -2
  122. package/src/service-manager-probe.ts +2 -3
  123. package/src/types.ts +76 -6
  124. package/src/update/index.ts +6 -5
  125. package/src/update/job.ts +5 -6
  126. package/src/update/notify.ts +5 -3
  127. package/src/usage/log.ts +11 -1
  128. package/src/vision/index.ts +21 -4
  129. package/src/web-search/index.ts +2 -1
  130. package/gui/dist/assets/index-CXI1262_.js +0 -76
  131. package/gui/dist/assets/index-DUCH59lJ.css +0 -1
@@ -1,6 +1,7 @@
1
1
  import type { Server } from "bun";
2
2
  import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
3
3
  import { formatPassthroughUpstreamError } from "./passthrough-error";
4
+ import { checkInputAdmission } from "./input-admission";
4
5
  import { describeUpstreamConnectFailure } from "./upstream-error";
5
6
  import {
6
7
  getConfigPath,
@@ -39,6 +40,7 @@ import {
39
40
  comboDefaultEffort,
40
41
  comboFailureDecision,
41
42
  comboIdFromRawBody,
43
+ comboRequestHasImageInput,
42
44
  concreteComboRequestBody,
43
45
  getCombo,
44
46
  isComboTargetInCooldown,
@@ -76,8 +78,8 @@ import {
76
78
  } from "../../oauth/anthropic-routing";
77
79
  import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
78
80
  import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
79
- import { describeImagesInPlace, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
80
- import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
81
+ import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
82
+ import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue";
81
83
  import {
82
84
  applyCodexAuthContextToProvider,
83
85
  CodexAccountCooldownError,
@@ -115,6 +117,11 @@ import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } f
115
117
  import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget";
116
118
  import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
117
119
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
120
+ import { SERVICE_TIER_ADAPTERS, serviceTierSupportForModel } from "../../providers/service-tier";
121
+ import {
122
+ RequestPacingQueueOverloadError,
123
+ waitForProviderRequestSlot,
124
+ } from "../../providers/request-pacing";
118
125
  import { slugsEquivalent } from "../../providers/slug-codec";
119
126
  import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
120
127
  import { isUsageDebugEnabled } from "../../usage/debug";
@@ -149,7 +156,6 @@ import {
149
156
  import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
150
157
  import {
151
158
  beginRequestAttempt,
152
- catalogModelSupportsServiceTier,
153
159
  finishRequestAttempt,
154
160
  inspectResponseLogJson,
155
161
  noteAttemptSend,
@@ -234,6 +240,10 @@ import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-t
234
240
  import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
235
241
  import { responsesJsonToSseStream } from "../responses-json-events";
236
242
  import { guardTerminalEventStream } from "./terminal-guard";
243
+ import {
244
+ emptyCompletionRetryEnabled,
245
+ guardEmptyCompletionEventStream,
246
+ } from "./empty-completion-guard";
237
247
 
238
248
  /**
239
249
  * Adapters whose continuation state must survive Codex's store:false requests.
@@ -592,7 +602,10 @@ async function retryCodexPoolOnAlternateAccount(
592
602
  upstream.signal,
593
603
  connectMs,
594
604
  stream,
595
- providerFetch(route.provider, options.codexWsRuntimeIdentity),
605
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
606
+ providerName: route.providerName,
607
+ modelId: route.modelId,
608
+ }),
596
609
  // Credential-bearing forward send: never follow a redirect into a
597
610
  // dead-host rejection after the credential was seen (#914).
598
611
  route.provider.authMode === "forward",
@@ -1077,9 +1090,17 @@ async function applyFinalRouteRequestNormalization(args: {
1077
1090
  logCtx.preserveResolvedModelFromRoute = true;
1078
1091
  }
1079
1092
 
1080
- // Fast mode override for OpenAI-routed models, only where the provider's Responses
1081
- // route documents `service_tier` support (capability gate below strips everywhere else).
1082
- if (config.fastMode !== undefined && route.provider.adapter === "openai-responses" && route.provider.supportsServiceTier === true) {
1093
+ // Fast mode override only where the final provider/model route explicitly documents
1094
+ // service-tier support. The same model-scoped resolver is used by catalog generation.
1095
+ const modelServiceTierSupport = serviceTierSupportForModel(
1096
+ route.provider,
1097
+ route.modelId,
1098
+ route.providerName,
1099
+ inboundWire,
1100
+ );
1101
+ if (config.fastMode !== undefined
1102
+ && SERVICE_TIER_ADAPTERS.has(route.provider.adapter)
1103
+ && modelServiceTierSupport === true) {
1083
1104
  const tier = config.fastMode ? "priority" : undefined;
1084
1105
  if (parsed._rawBody && typeof parsed._rawBody === "object") {
1085
1106
  if (tier) (parsed._rawBody as Record<string, unknown>).service_tier = tier;
@@ -1087,8 +1108,15 @@ async function applyFinalRouteRequestNormalization(args: {
1087
1108
  }
1088
1109
  parsed.options.serviceTier = tier;
1089
1110
  }
1090
- applyServiceTierGate(route.provider, parsed._rawBody, parsed.options);
1091
- if (route.provider.adapter === "openai-responses" && route.provider.supportsServiceTier === false) {
1111
+ applyServiceTierGate(
1112
+ route.provider,
1113
+ parsed._rawBody,
1114
+ parsed.options,
1115
+ route.modelId,
1116
+ route.providerName,
1117
+ inboundWire,
1118
+ );
1119
+ if (modelServiceTierSupport === false) {
1092
1120
  logCtx.requestedServiceTier = undefined;
1093
1121
  logCtx.requestedSpeedLabel = undefined;
1094
1122
  }
@@ -1142,10 +1170,9 @@ async function applyFinalRouteRequestNormalization(args: {
1142
1170
  }
1143
1171
  }
1144
1172
  recordAttemptRequestedEffort(logCtx);
1145
- logCtx.modelSupportsServiceTier = catalogModelSupportsServiceTier(
1146
- route.modelId,
1147
- logCtx.requestedServiceTier ?? logCtx.configuredServiceTier,
1148
- );
1173
+ logCtx.modelSupportsServiceTier = SERVICE_TIER_ADAPTERS.has(route.provider.adapter)
1174
+ ? modelServiceTierSupport
1175
+ : undefined;
1149
1176
  }
1150
1177
 
1151
1178
 
@@ -1171,6 +1198,41 @@ export async function handleComboResponses(
1171
1198
  if (!combo) {
1172
1199
  return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`);
1173
1200
  }
1201
+ // Expand previous_response_id before image policy and child dispatch so a
1202
+ // continuation that only references prior images still fails closed when
1203
+ // imageInput is disabled (and so targets see the full replayed input).
1204
+ const body = expandPreviousResponseInput(rawBody);
1205
+ if (previousResponseReplayFailure(body)) {
1206
+ return formatErrorResponse(
1207
+ 400,
1208
+ "previous_response_not_found",
1209
+ "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
1210
+ );
1211
+ }
1212
+ // Missing state returns the original body without a failure marker. Reject
1213
+ // that unresolved continuation for image-disabled combos so a target cannot
1214
+ // resolve prior images out of band. A successful expansion yields a new
1215
+ // object (still carrying previous_response_id) and must not be treated as
1216
+ // unresolved — text-only stored continuations remain allowed.
1217
+ const requestedPreviousId = typeof (rawBody as { previous_response_id?: unknown } | null)?.previous_response_id === "string"
1218
+ ? (rawBody as { previous_response_id: string }).previous_response_id.trim()
1219
+ : "";
1220
+ const unresolvedPrevious = requestedPreviousId.length > 0 && body === rawBody;
1221
+ if (combo.imageInput === "disabled" && unresolvedPrevious) {
1222
+ return formatErrorResponse(
1223
+ 400,
1224
+ "previous_response_not_found",
1225
+ "Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
1226
+ );
1227
+ }
1228
+ if (combo.imageInput === "disabled" && comboRequestHasImageInput(body)) {
1229
+ return formatErrorResponse(400, "invalid_request_error", `Combo "${comboId}" does not accept image input`);
1230
+ }
1231
+ // Expansion already materialised prior input. Drop the id so the child
1232
+ // handleResponses path does not expand again and double-prepend history.
1233
+ if (body !== rawBody && body && typeof body === "object" && !Array.isArray(body)) {
1234
+ delete (body as Record<string, unknown>).previous_response_id;
1235
+ }
1174
1236
  const adoptFailedChildLog = (childLog: RequestLogContext): void => {
1175
1237
  // Attempts remain the complete physical history; the logical row mirrors the most recent
1176
1238
  // failed target so an exhausted combo still has useful top-level reasoning diagnostics.
@@ -1187,7 +1249,7 @@ export async function handleComboResponses(
1187
1249
  };
1188
1250
 
1189
1251
  const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
1190
- (rawBody as { input?: unknown } | undefined)?.input,
1252
+ (body as { input?: unknown } | undefined)?.input,
1191
1253
  );
1192
1254
  const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => {
1193
1255
  const provider = config.providers[target.provider];
@@ -1229,7 +1291,7 @@ export async function handleComboResponses(
1229
1291
  };
1230
1292
  const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
1231
1293
  const childBody = concreteComboRequestBody(
1232
- rawBody,
1294
+ body,
1233
1295
  pick.target,
1234
1296
  comboDefaultEffort(config, comboId),
1235
1297
  supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
@@ -1450,8 +1512,19 @@ export function applyServiceTierGate(
1450
1512
  provider: OcxProviderConfig,
1451
1513
  rawBody: unknown,
1452
1514
  options: { serviceTier?: string },
1515
+ modelId?: string,
1516
+ providerName?: string,
1517
+ inbound: InboundWire = "responses",
1453
1518
  ): void {
1454
- if (provider.adapter !== "openai-responses" || provider.supportsServiceTier !== false) return;
1519
+ // A direct unit caller without a model id retains the historical tri-state behavior for
1520
+ // adapters outside the OpenAI service-tier family. Once a model is known, resolve the final
1521
+ // model adapter as well: an explicit override to Anthropic (or another non-OpenAI wire) must
1522
+ // not carry a caller-supplied `service_tier` through a route that cannot forward it.
1523
+ if (modelId === undefined && !SERVICE_TIER_ADAPTERS.has(provider.adapter)) return;
1524
+ const support = modelId === undefined
1525
+ ? provider.supportsServiceTier
1526
+ : serviceTierSupportForModel(provider, modelId, providerName, inbound);
1527
+ if (support !== false) return;
1455
1528
  if (rawBody && typeof rawBody === "object") {
1456
1529
  delete (rawBody as Record<string, unknown>).service_tier;
1457
1530
  }
@@ -1853,6 +1926,24 @@ async function handleResponsesInner(
1853
1926
  }
1854
1927
 
1855
1928
  if (options.abortSignal?.aborted) return clientCancelledResponse();
1929
+ // Refuse an input that cannot plausibly fit the model context window before spending auth,
1930
+ // circuit budget, or upstream bandwidth on a turn the provider will reject anyway (#1412).
1931
+ //
1932
+ // Compaction turns are exempt: Codex sends compaction_trigger BECAUSE context is full, so
1933
+ // refusing the turn that shrinks the context would deadlock the client against the very
1934
+ // limit this gate reports — it would be told to compact and then denied the compaction.
1935
+ if (parsed._compactionRequest !== true) {
1936
+ const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId);
1937
+ if (!inputAdmission.admitted) {
1938
+ return formatErrorResponse(
1939
+ 413,
1940
+ "request_too_large",
1941
+ `Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window `
1942
+ + `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a `
1943
+ + `model with a larger context window.`,
1944
+ );
1945
+ }
1946
+ }
1856
1947
  const preAuthHostKey = preAuthUpstreamHostCircuitKey(route, config);
1857
1948
  if (preAuthHostKey) {
1858
1949
  const admission = acquireUpstreamHostAdmission(
@@ -2076,7 +2167,7 @@ async function handleResponsesInner(
2076
2167
  recordSidecarOutcome,
2077
2168
  translatorBudget,
2078
2169
  );
2079
- } else if (modelInList(route.provider.noVisionModels, route.modelId)) {
2170
+ } else if (isModelTextOnly(route.provider, route.modelId)) {
2080
2171
  // Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar
2081
2172
  // disabled): fail closed — never forward raw images to a text-only upstream.
2082
2173
  stripImagesInPlace(parsed, translatorBudget);
@@ -2282,7 +2373,10 @@ async function handleResponsesInner(
2282
2373
  headers: request.headers,
2283
2374
  body: request.body,
2284
2375
  }, recovery), upstream.signal, connectMs, parsed.stream,
2285
- providerFetch(route.provider, options.codexWsRuntimeIdentity),
2376
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
2377
+ providerName: route.providerName,
2378
+ modelId: route.modelId,
2379
+ }),
2286
2380
  route.provider.authMode === "forward")
2287
2381
  // Every real attempt response — including an intermediate 5xx the
2288
2382
  // retry wrapper replaces — proves the host was reached (#914 review).
@@ -2344,7 +2438,10 @@ async function handleResponsesInner(
2344
2438
  headers: request.headers,
2345
2439
  body: request.body,
2346
2440
  }, recovery), upstream.signal, connectMs, parsed.stream,
2347
- providerFetch(route.provider, options.codexWsRuntimeIdentity),
2441
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
2442
+ providerName: route.providerName,
2443
+ modelId: route.modelId,
2444
+ }),
2348
2445
  route.provider.authMode === "forward")
2349
2446
  .then(res => {
2350
2447
  settleObservedHostResponse();
@@ -2649,6 +2746,7 @@ async function handleResponsesInner(
2649
2746
  } else {
2650
2747
  logCtx.transportPhase = "mid_stream";
2651
2748
  logCtx.terminalSource = "synthetic";
2749
+ if (logCtx.activeAttempt) logCtx.activeAttempt.streamAborted = true;
2652
2750
  reportNativeTerminal("failed", 502);
2653
2751
  }
2654
2752
  },
@@ -2932,6 +3030,11 @@ async function handleResponsesInner(
2932
3030
  && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) {
2933
3031
  parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME };
2934
3032
  }
3033
+ const imageProviderFetch = providerFetch(
3034
+ route.provider,
3035
+ options.codexWsRuntimeIdentity,
3036
+ { providerName: route.providerName, modelId: route.modelId },
3037
+ );
2935
3038
  const imgResponse = await runWithImageBridge({
2936
3039
  parsed, adapter,
2937
3040
  incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
@@ -2948,7 +3051,8 @@ async function handleResponsesInner(
2948
3051
  : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2),
2949
3052
  connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
2950
3053
  stallTimeoutSec: config.stallTimeoutSec,
2951
- fetchImpl: providerFetch(route.provider, options.codexWsRuntimeIdentity),
3054
+ waitForRequestSlot: imageProviderFetch.waitForPacing,
3055
+ fetchImpl: imageProviderFetch.unpacedFetch ?? imageProviderFetch,
2952
3056
  onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
2953
3057
  ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}),
2954
3058
  onUsage: usage => {
@@ -3077,34 +3181,88 @@ async function handleResponsesInner(
3077
3181
  return wsResponse;
3078
3182
  }
3079
3183
 
3184
+ // Empty-completion guard (codex-router PR #145 port): a 200 that completes with no output
3185
+ // text and no tool call is a failure the client cannot see — it silently records the turn as
3186
+ // done. The guard holds pre-content adapter events, suppresses the terminal of an empty
3187
+ // turn, retries the IDENTICAL request once, and surfaces a stated error when the retry is
3188
+ // empty or fails. This is a top-level config opt-in; OCX_EMPTY_COMPLETION_RETRY=0 is a
3189
+ // disable-only emergency override. Compaction turns and combo attempts keep their own
3190
+ // machinery (the combo preflight already handles empty streams). Native Chat-to-Chat
3191
+ // requests return from handleChatCompletions before entering Responses core, so they are
3192
+ // intentionally outside this guard and retain their existing one-send wire behavior.
3193
+ const emptyCompletionGuardEnabled =
3194
+ emptyCompletionRetryEnabled(config)
3195
+ && !options.comboAttempt
3196
+ && !routedCompaction;
3197
+
3080
3198
  if (adapter.runTurn) {
3081
3199
  const runTurnAbort = new AbortController();
3082
- linkAbortSignal(runTurnAbort, options.abortSignal);
3200
+ const cleanupRunTurnAbort = linkAbortSignal(runTurnAbort, options.abortSignal);
3083
3201
  const queue = createAdapterEventQueue({
3084
3202
  onBacklogExceeded: () => runTurnAbort.abort(),
3085
3203
  });
3086
- const runTurn = async (): Promise<void> => {
3204
+ // Initial admission must settle before the streaming Response commits HTTP 200.
3205
+ // Let the outer Responses facade preserve the local retryable-429 contract.
3206
+ try {
3207
+ await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal);
3208
+ } catch (error) {
3209
+ cleanupRunTurnAbort();
3210
+ queue.close();
3211
+ throw error;
3212
+ }
3213
+ // One attempt of the runTurn transport, against an explicit queue. The
3214
+ // empty-completion guard re-invokes the IDENTICAL turn (same parsed request,
3215
+ // same forwarded headers, same abort signal) through a fresh queue, so the
3216
+ // attempt body must not capture the first queue. Each attempt consumes its
3217
+ // own provider pacing slot (#1584): retries are paced like first attempts.
3218
+ const runTurnAttempt = async (
3219
+ targetQueue: AdapterEventQueue,
3220
+ recovery?: AttemptRecoveryKind,
3221
+ pacingSlotAcquired = false,
3222
+ ): Promise<void> => {
3087
3223
  try {
3088
- noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
3224
+ if (!pacingSlotAcquired) {
3225
+ await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal);
3226
+ }
3227
+ noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery);
3089
3228
  await adapter.runTurn?.(
3090
3229
  parsed,
3091
3230
  { headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal, translatorBudget },
3092
- queue.push,
3231
+ targetQueue.push,
3093
3232
  );
3094
3233
  } catch (err) {
3095
- queue.push({
3096
- type: "error",
3097
- message: err instanceof Error ? err.message : String(err),
3098
- });
3234
+ targetQueue.push(err instanceof RequestPacingQueueOverloadError
3235
+ ? {
3236
+ type: "error",
3237
+ status: 429,
3238
+ errorType: "rate_limit_error",
3239
+ retryable: true,
3240
+ message: err.message,
3241
+ }
3242
+ : {
3243
+ type: "error",
3244
+ message: err instanceof Error ? err.message : String(err),
3245
+ });
3099
3246
  } finally {
3100
3247
  // Cursor assigns a stable conversation id inside runTurn on the first headerless
3101
3248
  // turn; backfill so Logs can filter/total that opening request (#330 / #522).
3102
3249
  if (!logCtx.conversationId && parsed._cursorConversationId) {
3103
3250
  logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
3104
3251
  }
3105
- queue.close();
3252
+ targetQueue.close();
3106
3253
  }
3107
3254
  };
3255
+ const runTurn = async (): Promise<void> => runTurnAttempt(queue, undefined, true);
3256
+ // The empty-completion retry re-runs the turn against a fresh queue: the
3257
+ // first queue is closed once its attempt settles, and pushing into it after
3258
+ // close is a silent no-op.
3259
+ const runTurnRetrySource = (): AsyncIterable<AdapterEvent> => {
3260
+ const retryQueue = createAdapterEventQueue({
3261
+ onBacklogExceeded: () => runTurnAbort.abort(),
3262
+ });
3263
+ void runTurnAttempt(retryQueue, "empty-completion");
3264
+ return retryQueue.stream();
3265
+ };
3108
3266
 
3109
3267
  const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
3110
3268
  if (parsed.stream) {
@@ -3120,8 +3278,16 @@ async function handleResponsesInner(
3120
3278
  }
3121
3279
  eventSource = preflight.stream;
3122
3280
  }
3281
+ const guardedSource = emptyCompletionGuardEnabled
3282
+ ? guardEmptyCompletionEventStream({
3283
+ firstEvents: eventSource,
3284
+ // Identical-turn retry: same parsed request, same headers, same
3285
+ // signal — run the adapter transport again against a fresh queue.
3286
+ continuation: runTurnRetrySource,
3287
+ })
3288
+ : eventSource;
3123
3289
  const sseStream = bridgeToResponsesSSE(
3124
- eventSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
3290
+ guardedSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
3125
3291
  () => {
3126
3292
  runTurnAbort.abort();
3127
3293
  queue.close();
@@ -3164,7 +3330,17 @@ async function handleResponsesInner(
3164
3330
  }
3165
3331
 
3166
3332
  await runTurn();
3167
- const events = await queue.collect();
3333
+ const firstAttemptEvents = await queue.collect();
3334
+ let events: AdapterEvent[];
3335
+ if (emptyCompletionGuardEnabled) {
3336
+ events = [];
3337
+ for await (const event of guardEmptyCompletionEventStream({
3338
+ firstEvents: (async function* () { yield* firstAttemptEvents; })(),
3339
+ continuation: runTurnRetrySource,
3340
+ })) events.push(event);
3341
+ } else {
3342
+ events = firstAttemptEvents;
3343
+ }
3168
3344
  if (options.comboAttempt) {
3169
3345
  const firstMeaningful = events.find(event => event.type !== "heartbeat");
3170
3346
  if (!firstMeaningful || firstMeaningful.type === "error") {
@@ -3259,6 +3435,7 @@ async function handleResponsesInner(
3259
3435
  try {
3260
3436
  if (activeAdapter.fetchResponse) {
3261
3437
  noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate);
3438
+ await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
3262
3439
  upstreamResponse = await activeAdapter.fetchResponse(builtInitialRequest, {
3263
3440
  abortSignal: upstream.signal,
3264
3441
  timeoutMs: connectMs,
@@ -3273,7 +3450,10 @@ async function handleResponsesInner(
3273
3450
  headers: builtInitialRequest.headers,
3274
3451
  body: builtInitialRequest.body,
3275
3452
  }, recovery), upstream.signal, connectMs, parsed.stream,
3276
- providerFetch(route.provider, options.codexWsRuntimeIdentity));
3453
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
3454
+ providerName: route.providerName,
3455
+ modelId: route.modelId,
3456
+ }));
3277
3457
  },
3278
3458
  { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url) },
3279
3459
  );
@@ -3348,12 +3528,17 @@ async function handleResponsesInner(
3348
3528
  noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
3349
3529
  try {
3350
3530
  try {
3351
- return activeAdapter.fetchResponse
3352
- ? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
3353
- : await fetchWithHeaderTimeout(retryRequest.url, {
3354
- method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
3355
- }, upstream.signal, connectMs, parsed.stream,
3356
- providerFetch(route.provider, options.codexWsRuntimeIdentity));
3531
+ if (activeAdapter.fetchResponse) {
3532
+ await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
3533
+ return await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream });
3534
+ }
3535
+ return await fetchWithHeaderTimeout(retryRequest.url, {
3536
+ method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
3537
+ }, upstream.signal, connectMs, parsed.stream,
3538
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
3539
+ providerName: route.providerName,
3540
+ modelId: route.modelId,
3541
+ }));
3357
3542
  } finally {
3358
3543
  retryRequest.releaseBodyObservation?.();
3359
3544
  }
@@ -3602,22 +3787,30 @@ async function handleResponsesInner(
3602
3787
 
3603
3788
  cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
3604
3789
 
3605
- // Anthropic-only: one bounded internal continuation re-ask for clean end_turn turns that
3606
- // announced an edit without emitting a tool call.
3607
- const terminalGuardEnabled = activeAdapter.name === "anthropic" && !options.comboAttempt && !routedCompaction;
3790
+ // One bounded internal continuation re-ask for clean end_turn turns that announced an edit
3791
+ // without emitting a tool call. Anthropic gets this by default; openai-chat providers opt in
3792
+ // per-provider via `terminalContinuationGuard` (the heuristic was tuned on Anthropic turns,
3793
+ // so it stays off for the shared openai-chat adapter unless a provider enables it).
3794
+ const terminalGuardEnabled = (activeAdapter.name === "anthropic"
3795
+ || (activeAdapter.name === "openai-chat" && route.provider.terminalContinuationGuard === true))
3796
+ && !options.comboAttempt && !routedCompaction;
3608
3797
  /**
3609
3798
  * One bounded internal re-ask for Anthropic end_turn-without-tool-call turns. Replays the
3610
3799
  * continuation on a 429 with the same-key retry budget (hoisted per request), then falls
3611
3800
  * back to key/account failover; a failure becomes an in-stream adapter error so the client
3612
3801
  * never sees a second hidden HTTP response or an unbounded retry loop.
3613
3802
  */
3614
- const fetchTerminalGuardContinuation = async function* (nextParsed: OcxParsedRequest): AsyncGenerator<AdapterEvent> {
3803
+ const fetchTerminalGuardContinuation = async function* (
3804
+ nextParsed: OcxParsedRequest,
3805
+ initialRecoveryKind?: AttemptRecoveryKind,
3806
+ ): AsyncGenerator<AdapterEvent> {
3615
3807
  let response: Response | undefined;
3616
3808
  // One-shot recovery label for the next top-of-loop continuation send after a failover rotation.
3617
- let nextContinuationRecoveryKind: AttemptRecoveryKind | undefined;
3809
+ let nextContinuationRecoveryKind: AttemptRecoveryKind | undefined = initialRecoveryKind;
3618
3810
  /**
3619
3811
  * Build and fetch one terminal-guard continuation. `recoveryKind` tags same-target and
3620
- * failover sends (`rate-limit-429`, `key-429`, `anthropic-oauth-429`, `image-413`); the
3812
+ * failover sends (`empty-completion`, `rate-limit-429`, `key-429`,
3813
+ * `anthropic-oauth-429`, `image-413`); the
3621
3814
  * adapter rebuild is deterministic for the same parsed request (tests assert byte-identical
3622
3815
  * replays).
3623
3816
  */
@@ -3658,6 +3851,7 @@ async function handleResponsesInner(
3658
3851
  try {
3659
3852
  if (activeAdapter.fetchResponse) {
3660
3853
  noteAttemptSend(logCtx.activeAttempt, continuationEstimate, replayKind);
3854
+ await waitForProviderRequestSlot(route.providerName, route.provider, nextParsed.modelId, upstream.signal);
3661
3855
  return await activeAdapter.fetchResponse(builtContinuationRequest, {
3662
3856
  abortSignal: upstream.signal,
3663
3857
  timeoutMs: connectMs,
@@ -3677,7 +3871,10 @@ async function handleResponsesInner(
3677
3871
  upstream.signal,
3678
3872
  connectMs,
3679
3873
  nextParsed.stream,
3680
- providerFetch(route.provider, options.codexWsRuntimeIdentity),
3874
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
3875
+ providerName: route.providerName,
3876
+ modelId: nextParsed.modelId,
3877
+ }),
3681
3878
  );
3682
3879
  },
3683
3880
  { abortSignal: upstream.signal, label: safeHostLabel(builtContinuationRequest.url) },
@@ -3861,6 +4058,19 @@ async function handleResponsesInner(
3861
4058
  }
3862
4059
  };
3863
4060
 
4061
+ const fetchGuardedEmptyCompletionRetry = (): AsyncIterable<AdapterEvent> => {
4062
+ const retryEvents = fetchTerminalGuardContinuation(parsed, "empty-completion");
4063
+ return terminalGuardEnabled
4064
+ ? guardTerminalEventStream({
4065
+ parsed,
4066
+ firstEvents: retryEvents,
4067
+ adapterName: activeAdapter.name,
4068
+ maxAutoContinuations: 1,
4069
+ continuation: fetchTerminalGuardContinuation,
4070
+ })
4071
+ : retryEvents;
4072
+ };
4073
+
3864
4074
  if (parsed.stream) {
3865
4075
  const initialEventStream = activeAdapter.parseStream(upstreamResponse, translatorBudget);
3866
4076
  const eventStream = terminalGuardEnabled
@@ -3872,9 +4082,19 @@ async function handleResponsesInner(
3872
4082
  continuation: fetchTerminalGuardContinuation,
3873
4083
  })
3874
4084
  : initialEventStream;
4085
+ // The empty-completion guard sits OUTSIDE the terminal guard: a completed
4086
+ // turn with no text and no tool call is retried with the IDENTICAL request
4087
+ // (fetchTerminalGuardContinuation(parsed) replays the cached byte-identical
4088
+ // request — same body, same headers, same signal).
4089
+ const guardedEventStream = emptyCompletionGuardEnabled
4090
+ ? guardEmptyCompletionEventStream({
4091
+ firstEvents: eventStream,
4092
+ continuation: fetchGuardedEmptyCompletionRetry,
4093
+ })
4094
+ : eventStream;
3875
4095
  const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
3876
4096
  const sseStream = bridgeToResponsesSSE(
3877
- eventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
4097
+ guardedEventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
3878
4098
  () => upstream.abort(), 2_000,
3879
4099
  {
3880
4100
  translatorBudget,
@@ -3919,17 +4139,27 @@ async function handleResponsesInner(
3919
4139
  let events: AdapterEvent[];
3920
4140
  try {
3921
4141
  const initialEvents = await activeAdapter.parseResponse(upstreamResponse, translatorBudget);
4142
+ let guardedEvents: AdapterEvent[];
3922
4143
  if (terminalGuardEnabled) {
3923
- events = [];
4144
+ guardedEvents = [];
3924
4145
  for await (const event of guardTerminalEventStream({
3925
4146
  parsed,
3926
4147
  firstEvents: (async function* () { yield* initialEvents; })(),
3927
4148
  adapterName: activeAdapter.name,
3928
4149
  maxAutoContinuations: 1,
3929
4150
  continuation: fetchTerminalGuardContinuation,
4151
+ })) guardedEvents.push(event);
4152
+ } else {
4153
+ guardedEvents = initialEvents;
4154
+ }
4155
+ if (emptyCompletionGuardEnabled) {
4156
+ events = [];
4157
+ for await (const event of guardEmptyCompletionEventStream({
4158
+ firstEvents: (async function* () { yield* guardedEvents; })(),
4159
+ continuation: fetchGuardedEmptyCompletionRetry,
3930
4160
  })) events.push(event);
3931
4161
  } else {
3932
- events = initialEvents;
4162
+ events = guardedEvents;
3933
4163
  }
3934
4164
  } finally {
3935
4165
  cleanupUpstreamAbort();