@bitkyc08/opencodex 2.19.0 → 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 (119) 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/mimo-free.ts +17 -0
  6. package/src/adapters/openai-chat.ts +367 -32
  7. package/src/adapters/registry.ts +144 -0
  8. package/src/chat/inbound.ts +13 -7
  9. package/src/cli/claude.ts +2 -1
  10. package/src/cli/combo.ts +3 -0
  11. package/src/cli/dispatch.ts +8 -0
  12. package/src/cli/export-command.ts +2 -2
  13. package/src/cli/help.ts +2 -0
  14. package/src/cli/index.ts +3 -2
  15. package/src/cli/lab.ts +135 -1
  16. package/src/cli/minimax.ts +491 -0
  17. package/src/cli/models-runtime.ts +22 -1
  18. package/src/cli/models.ts +67 -2
  19. package/src/cli/opencode.ts +2 -1
  20. package/src/cli/registry.ts +22 -2
  21. package/src/clients/config-export.ts +125 -7
  22. package/src/codex/app-server-processes.ts +57 -2
  23. package/src/codex/app-server-restart-service.ts +232 -0
  24. package/src/codex/catalog/aggregation.ts +10 -1
  25. package/src/codex/catalog/effort.ts +15 -3
  26. package/src/codex/catalog/parsing.ts +3 -1
  27. package/src/codex/catalog/provider-fetch.ts +45 -5
  28. package/src/codex/catalog/sync.ts +74 -4
  29. package/src/codex/convergence.ts +2 -0
  30. package/src/combos/index.ts +1 -0
  31. package/src/combos/request.ts +30 -0
  32. package/src/combos/types.ts +6 -0
  33. package/src/config.ts +52 -0
  34. package/src/generated/compatibility-version.json +224 -76
  35. package/src/images/loop.ts +11 -1
  36. package/src/integrations/registry.ts +7 -0
  37. package/src/lab/conformance/jcs.ts +42 -2
  38. package/src/lab/conformance/negative-controls.ts +6 -2
  39. package/src/lab/conformance/runner.ts +16 -5
  40. package/src/lab/fabric/observe.ts +49 -14
  41. package/src/lab/index.ts +16 -0
  42. package/src/lab/ledger/purge.ts +152 -83
  43. package/src/lab/ledger/store.ts +168 -54
  44. package/src/lab/observe/from-conformance.ts +8 -6
  45. package/src/lab/observe/from-live.ts +8 -2
  46. package/src/lab/paths.ts +23 -0
  47. package/src/lab/public/bundle.ts +217 -0
  48. package/src/lab/public/community-authority.ts +175 -0
  49. package/src/lab/public/community-files.ts +29 -0
  50. package/src/lab/public/community.ts +479 -0
  51. package/src/lab/public/file-safety.ts +155 -0
  52. package/src/lab/public/ids.ts +26 -0
  53. package/src/lab/public/index.ts +16 -0
  54. package/src/lab/public/mutation-lock.ts +424 -0
  55. package/src/lab/public/operator.ts +353 -0
  56. package/src/lab/public/origin-purge.ts +79 -0
  57. package/src/lab/public/origin.ts +203 -0
  58. package/src/lab/public/privacy.ts +143 -0
  59. package/src/lab/public/private-file.ts +261 -0
  60. package/src/lab/public/project.ts +124 -0
  61. package/src/lab/public/purge-test-fault.ts +21 -0
  62. package/src/lab/public/purge.ts +223 -0
  63. package/src/lab/public/registry.ts +44 -0
  64. package/src/lab/public/revocation.ts +252 -0
  65. package/src/lab/public/signature.ts +219 -0
  66. package/src/lab/public/storage.ts +105 -0
  67. package/src/lab/public/strict-json.ts +206 -0
  68. package/src/lab/public/time.ts +26 -0
  69. package/src/lab/public/types.ts +172 -0
  70. package/src/lab/public/validate.ts +391 -0
  71. package/src/lib/codex-restart-contract.ts +120 -0
  72. package/src/lib/lab-activation.ts +109 -47
  73. package/src/lib/lab-live-pinned-sender.ts +16 -5
  74. package/src/lib/pinned-http.ts +70 -16
  75. package/src/lib/self-launch-argv.ts +15 -0
  76. package/src/lib/state-store-registrations.ts +2 -0
  77. package/src/lib/upstream-reachability.ts +4 -0
  78. package/src/lib/windows-elevation.ts +10 -1
  79. package/src/providers/derive.ts +24 -4
  80. package/src/providers/registry.ts +7 -3
  81. package/src/providers/request-pacing.ts +310 -0
  82. package/src/providers/service-tier.ts +143 -0
  83. package/src/providers/static-model-discovery.ts +86 -0
  84. package/src/reasoning-effort.ts +27 -1
  85. package/src/router.ts +23 -6
  86. package/src/routing/capability.ts +4 -2
  87. package/src/routing/compatibility/behavior.ts +5 -1
  88. package/src/server/adapter-resolve.ts +2 -32
  89. package/src/server/auth-cors.ts +8 -0
  90. package/src/server/chat-completions.ts +74 -36
  91. package/src/server/chat-native-sse.ts +331 -0
  92. package/src/server/chat-native.ts +371 -0
  93. package/src/server/management/combo-routes.ts +16 -2
  94. package/src/server/management/config-routes.ts +6 -4
  95. package/src/server/management/context.ts +17 -0
  96. package/src/server/management/lab-routes.ts +181 -19
  97. package/src/server/management/model-routes.ts +76 -2
  98. package/src/server/management/model-rows.ts +8 -0
  99. package/src/server/management/provider-capability-config.ts +48 -0
  100. package/src/server/management/provider-routes.ts +76 -4
  101. package/src/server/management/system-restart.ts +4 -2
  102. package/src/server/management/system-routes.ts +38 -0
  103. package/src/server/relay.ts +17 -3
  104. package/src/server/responses/compact.ts +4 -1
  105. package/src/server/responses/core.ts +257 -46
  106. package/src/server/responses/empty-completion-guard.ts +276 -0
  107. package/src/server/responses/fetch-helpers.ts +35 -4
  108. package/src/server/responses/pacing-overload.ts +13 -0
  109. package/src/server/responses/policy-fallback.ts +16 -2
  110. package/src/server/responses/terminal-guard.ts +1 -1
  111. package/src/server/responses/upstream-error.ts +5 -0
  112. package/src/server/responses.ts +17 -2
  113. package/src/types.ts +66 -4
  114. package/src/update/index.ts +6 -5
  115. package/src/update/job.ts +5 -6
  116. package/src/update/notify.ts +5 -3
  117. package/src/usage/log.ts +11 -1
  118. package/gui/dist/assets/index-CQ7bIKee.css +0 -1
  119. package/gui/dist/assets/index-D_JUZLEC.js +0 -76
@@ -40,6 +40,7 @@ import {
40
40
  comboDefaultEffort,
41
41
  comboFailureDecision,
42
42
  comboIdFromRawBody,
43
+ comboRequestHasImageInput,
43
44
  concreteComboRequestBody,
44
45
  getCombo,
45
46
  isComboTargetInCooldown,
@@ -78,7 +79,7 @@ import {
78
79
  import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
79
80
  import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
80
81
  import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
81
- import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
82
+ import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue";
82
83
  import {
83
84
  applyCodexAuthContextToProvider,
84
85
  CodexAccountCooldownError,
@@ -116,6 +117,11 @@ import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } f
116
117
  import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget";
117
118
  import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
118
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";
119
125
  import { slugsEquivalent } from "../../providers/slug-codec";
120
126
  import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models";
121
127
  import { isUsageDebugEnabled } from "../../usage/debug";
@@ -150,7 +156,6 @@ import {
150
156
  import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
151
157
  import {
152
158
  beginRequestAttempt,
153
- catalogModelSupportsServiceTier,
154
159
  finishRequestAttempt,
155
160
  inspectResponseLogJson,
156
161
  noteAttemptSend,
@@ -235,6 +240,10 @@ import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-t
235
240
  import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
236
241
  import { responsesJsonToSseStream } from "../responses-json-events";
237
242
  import { guardTerminalEventStream } from "./terminal-guard";
243
+ import {
244
+ emptyCompletionRetryEnabled,
245
+ guardEmptyCompletionEventStream,
246
+ } from "./empty-completion-guard";
238
247
 
239
248
  /**
240
249
  * Adapters whose continuation state must survive Codex's store:false requests.
@@ -593,7 +602,10 @@ async function retryCodexPoolOnAlternateAccount(
593
602
  upstream.signal,
594
603
  connectMs,
595
604
  stream,
596
- providerFetch(route.provider, options.codexWsRuntimeIdentity),
605
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
606
+ providerName: route.providerName,
607
+ modelId: route.modelId,
608
+ }),
597
609
  // Credential-bearing forward send: never follow a redirect into a
598
610
  // dead-host rejection after the credential was seen (#914).
599
611
  route.provider.authMode === "forward",
@@ -1078,9 +1090,17 @@ async function applyFinalRouteRequestNormalization(args: {
1078
1090
  logCtx.preserveResolvedModelFromRoute = true;
1079
1091
  }
1080
1092
 
1081
- // Fast mode override for OpenAI-routed models, only where the provider's Responses
1082
- // route documents `service_tier` support (capability gate below strips everywhere else).
1083
- 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) {
1084
1104
  const tier = config.fastMode ? "priority" : undefined;
1085
1105
  if (parsed._rawBody && typeof parsed._rawBody === "object") {
1086
1106
  if (tier) (parsed._rawBody as Record<string, unknown>).service_tier = tier;
@@ -1088,8 +1108,15 @@ async function applyFinalRouteRequestNormalization(args: {
1088
1108
  }
1089
1109
  parsed.options.serviceTier = tier;
1090
1110
  }
1091
- applyServiceTierGate(route.provider, parsed._rawBody, parsed.options);
1092
- 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) {
1093
1120
  logCtx.requestedServiceTier = undefined;
1094
1121
  logCtx.requestedSpeedLabel = undefined;
1095
1122
  }
@@ -1143,10 +1170,9 @@ async function applyFinalRouteRequestNormalization(args: {
1143
1170
  }
1144
1171
  }
1145
1172
  recordAttemptRequestedEffort(logCtx);
1146
- logCtx.modelSupportsServiceTier = catalogModelSupportsServiceTier(
1147
- route.modelId,
1148
- logCtx.requestedServiceTier ?? logCtx.configuredServiceTier,
1149
- );
1173
+ logCtx.modelSupportsServiceTier = SERVICE_TIER_ADAPTERS.has(route.provider.adapter)
1174
+ ? modelServiceTierSupport
1175
+ : undefined;
1150
1176
  }
1151
1177
 
1152
1178
 
@@ -1172,6 +1198,41 @@ export async function handleComboResponses(
1172
1198
  if (!combo) {
1173
1199
  return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`);
1174
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
+ }
1175
1236
  const adoptFailedChildLog = (childLog: RequestLogContext): void => {
1176
1237
  // Attempts remain the complete physical history; the logical row mirrors the most recent
1177
1238
  // failed target so an exhausted combo still has useful top-level reasoning diagnostics.
@@ -1188,7 +1249,7 @@ export async function handleComboResponses(
1188
1249
  };
1189
1250
 
1190
1251
  const unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
1191
- (rawBody as { input?: unknown } | undefined)?.input,
1252
+ (body as { input?: unknown } | undefined)?.input,
1192
1253
  );
1193
1254
  const canDecryptUnreadableAgentTask = (target: (typeof combo.targets)[number]): boolean => {
1194
1255
  const provider = config.providers[target.provider];
@@ -1230,7 +1291,7 @@ export async function handleComboResponses(
1230
1291
  };
1231
1292
  const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
1232
1293
  const childBody = concreteComboRequestBody(
1233
- rawBody,
1294
+ body,
1234
1295
  pick.target,
1235
1296
  comboDefaultEffort(config, comboId),
1236
1297
  supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
@@ -1451,8 +1512,19 @@ export function applyServiceTierGate(
1451
1512
  provider: OcxProviderConfig,
1452
1513
  rawBody: unknown,
1453
1514
  options: { serviceTier?: string },
1515
+ modelId?: string,
1516
+ providerName?: string,
1517
+ inbound: InboundWire = "responses",
1454
1518
  ): void {
1455
- 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;
1456
1528
  if (rawBody && typeof rawBody === "object") {
1457
1529
  delete (rawBody as Record<string, unknown>).service_tier;
1458
1530
  }
@@ -2301,7 +2373,10 @@ async function handleResponsesInner(
2301
2373
  headers: request.headers,
2302
2374
  body: request.body,
2303
2375
  }, recovery), upstream.signal, connectMs, parsed.stream,
2304
- providerFetch(route.provider, options.codexWsRuntimeIdentity),
2376
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
2377
+ providerName: route.providerName,
2378
+ modelId: route.modelId,
2379
+ }),
2305
2380
  route.provider.authMode === "forward")
2306
2381
  // Every real attempt response — including an intermediate 5xx the
2307
2382
  // retry wrapper replaces — proves the host was reached (#914 review).
@@ -2363,7 +2438,10 @@ async function handleResponsesInner(
2363
2438
  headers: request.headers,
2364
2439
  body: request.body,
2365
2440
  }, recovery), upstream.signal, connectMs, parsed.stream,
2366
- providerFetch(route.provider, options.codexWsRuntimeIdentity),
2441
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
2442
+ providerName: route.providerName,
2443
+ modelId: route.modelId,
2444
+ }),
2367
2445
  route.provider.authMode === "forward")
2368
2446
  .then(res => {
2369
2447
  settleObservedHostResponse();
@@ -2668,6 +2746,7 @@ async function handleResponsesInner(
2668
2746
  } else {
2669
2747
  logCtx.transportPhase = "mid_stream";
2670
2748
  logCtx.terminalSource = "synthetic";
2749
+ if (logCtx.activeAttempt) logCtx.activeAttempt.streamAborted = true;
2671
2750
  reportNativeTerminal("failed", 502);
2672
2751
  }
2673
2752
  },
@@ -2951,6 +3030,11 @@ async function handleResponsesInner(
2951
3030
  && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) {
2952
3031
  parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME };
2953
3032
  }
3033
+ const imageProviderFetch = providerFetch(
3034
+ route.provider,
3035
+ options.codexWsRuntimeIdentity,
3036
+ { providerName: route.providerName, modelId: route.modelId },
3037
+ );
2954
3038
  const imgResponse = await runWithImageBridge({
2955
3039
  parsed, adapter,
2956
3040
  incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget },
@@ -2967,7 +3051,8 @@ async function handleResponsesInner(
2967
3051
  : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2),
2968
3052
  connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
2969
3053
  stallTimeoutSec: config.stallTimeoutSec,
2970
- fetchImpl: providerFetch(route.provider, options.codexWsRuntimeIdentity),
3054
+ waitForRequestSlot: imageProviderFetch.waitForPacing,
3055
+ fetchImpl: imageProviderFetch.unpacedFetch ?? imageProviderFetch,
2971
3056
  onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
2972
3057
  ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}),
2973
3058
  onUsage: usage => {
@@ -3096,34 +3181,88 @@ async function handleResponsesInner(
3096
3181
  return wsResponse;
3097
3182
  }
3098
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
+
3099
3198
  if (adapter.runTurn) {
3100
3199
  const runTurnAbort = new AbortController();
3101
- linkAbortSignal(runTurnAbort, options.abortSignal);
3200
+ const cleanupRunTurnAbort = linkAbortSignal(runTurnAbort, options.abortSignal);
3102
3201
  const queue = createAdapterEventQueue({
3103
3202
  onBacklogExceeded: () => runTurnAbort.abort(),
3104
3203
  });
3105
- 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> => {
3106
3223
  try {
3107
- 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);
3108
3228
  await adapter.runTurn?.(
3109
3229
  parsed,
3110
3230
  { headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal, translatorBudget },
3111
- queue.push,
3231
+ targetQueue.push,
3112
3232
  );
3113
3233
  } catch (err) {
3114
- queue.push({
3115
- type: "error",
3116
- message: err instanceof Error ? err.message : String(err),
3117
- });
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
+ });
3118
3246
  } finally {
3119
3247
  // Cursor assigns a stable conversation id inside runTurn on the first headerless
3120
3248
  // turn; backfill so Logs can filter/total that opening request (#330 / #522).
3121
3249
  if (!logCtx.conversationId && parsed._cursorConversationId) {
3122
3250
  logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
3123
3251
  }
3124
- queue.close();
3252
+ targetQueue.close();
3125
3253
  }
3126
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
+ };
3127
3266
 
3128
3267
  const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
3129
3268
  if (parsed.stream) {
@@ -3139,8 +3278,16 @@ async function handleResponsesInner(
3139
3278
  }
3140
3279
  eventSource = preflight.stream;
3141
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;
3142
3289
  const sseStream = bridgeToResponsesSSE(
3143
- eventSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
3290
+ guardedSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
3144
3291
  () => {
3145
3292
  runTurnAbort.abort();
3146
3293
  queue.close();
@@ -3183,7 +3330,17 @@ async function handleResponsesInner(
3183
3330
  }
3184
3331
 
3185
3332
  await runTurn();
3186
- 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
+ }
3187
3344
  if (options.comboAttempt) {
3188
3345
  const firstMeaningful = events.find(event => event.type !== "heartbeat");
3189
3346
  if (!firstMeaningful || firstMeaningful.type === "error") {
@@ -3278,6 +3435,7 @@ async function handleResponsesInner(
3278
3435
  try {
3279
3436
  if (activeAdapter.fetchResponse) {
3280
3437
  noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate);
3438
+ await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
3281
3439
  upstreamResponse = await activeAdapter.fetchResponse(builtInitialRequest, {
3282
3440
  abortSignal: upstream.signal,
3283
3441
  timeoutMs: connectMs,
@@ -3292,7 +3450,10 @@ async function handleResponsesInner(
3292
3450
  headers: builtInitialRequest.headers,
3293
3451
  body: builtInitialRequest.body,
3294
3452
  }, recovery), upstream.signal, connectMs, parsed.stream,
3295
- providerFetch(route.provider, options.codexWsRuntimeIdentity));
3453
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
3454
+ providerName: route.providerName,
3455
+ modelId: route.modelId,
3456
+ }));
3296
3457
  },
3297
3458
  { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url) },
3298
3459
  );
@@ -3367,12 +3528,17 @@ async function handleResponsesInner(
3367
3528
  noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
3368
3529
  try {
3369
3530
  try {
3370
- return activeAdapter.fetchResponse
3371
- ? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
3372
- : await fetchWithHeaderTimeout(retryRequest.url, {
3373
- method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
3374
- }, upstream.signal, connectMs, parsed.stream,
3375
- 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
+ }));
3376
3542
  } finally {
3377
3543
  retryRequest.releaseBodyObservation?.();
3378
3544
  }
@@ -3621,22 +3787,30 @@ async function handleResponsesInner(
3621
3787
 
3622
3788
  cancelBodyOnAbort(upstreamResponse.body, upstream.signal);
3623
3789
 
3624
- // Anthropic-only: one bounded internal continuation re-ask for clean end_turn turns that
3625
- // announced an edit without emitting a tool call.
3626
- 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;
3627
3797
  /**
3628
3798
  * One bounded internal re-ask for Anthropic end_turn-without-tool-call turns. Replays the
3629
3799
  * continuation on a 429 with the same-key retry budget (hoisted per request), then falls
3630
3800
  * back to key/account failover; a failure becomes an in-stream adapter error so the client
3631
3801
  * never sees a second hidden HTTP response or an unbounded retry loop.
3632
3802
  */
3633
- const fetchTerminalGuardContinuation = async function* (nextParsed: OcxParsedRequest): AsyncGenerator<AdapterEvent> {
3803
+ const fetchTerminalGuardContinuation = async function* (
3804
+ nextParsed: OcxParsedRequest,
3805
+ initialRecoveryKind?: AttemptRecoveryKind,
3806
+ ): AsyncGenerator<AdapterEvent> {
3634
3807
  let response: Response | undefined;
3635
3808
  // One-shot recovery label for the next top-of-loop continuation send after a failover rotation.
3636
- let nextContinuationRecoveryKind: AttemptRecoveryKind | undefined;
3809
+ let nextContinuationRecoveryKind: AttemptRecoveryKind | undefined = initialRecoveryKind;
3637
3810
  /**
3638
3811
  * Build and fetch one terminal-guard continuation. `recoveryKind` tags same-target and
3639
- * 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
3640
3814
  * adapter rebuild is deterministic for the same parsed request (tests assert byte-identical
3641
3815
  * replays).
3642
3816
  */
@@ -3677,6 +3851,7 @@ async function handleResponsesInner(
3677
3851
  try {
3678
3852
  if (activeAdapter.fetchResponse) {
3679
3853
  noteAttemptSend(logCtx.activeAttempt, continuationEstimate, replayKind);
3854
+ await waitForProviderRequestSlot(route.providerName, route.provider, nextParsed.modelId, upstream.signal);
3680
3855
  return await activeAdapter.fetchResponse(builtContinuationRequest, {
3681
3856
  abortSignal: upstream.signal,
3682
3857
  timeoutMs: connectMs,
@@ -3696,7 +3871,10 @@ async function handleResponsesInner(
3696
3871
  upstream.signal,
3697
3872
  connectMs,
3698
3873
  nextParsed.stream,
3699
- providerFetch(route.provider, options.codexWsRuntimeIdentity),
3874
+ providerFetch(route.provider, options.codexWsRuntimeIdentity, {
3875
+ providerName: route.providerName,
3876
+ modelId: nextParsed.modelId,
3877
+ }),
3700
3878
  );
3701
3879
  },
3702
3880
  { abortSignal: upstream.signal, label: safeHostLabel(builtContinuationRequest.url) },
@@ -3880,6 +4058,19 @@ async function handleResponsesInner(
3880
4058
  }
3881
4059
  };
3882
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
+
3883
4074
  if (parsed.stream) {
3884
4075
  const initialEventStream = activeAdapter.parseStream(upstreamResponse, translatorBudget);
3885
4076
  const eventStream = terminalGuardEnabled
@@ -3891,9 +4082,19 @@ async function handleResponsesInner(
3891
4082
  continuation: fetchTerminalGuardContinuation,
3892
4083
  })
3893
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;
3894
4095
  const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
3895
4096
  const sseStream = bridgeToResponsesSSE(
3896
- eventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
4097
+ guardedEventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
3897
4098
  () => upstream.abort(), 2_000,
3898
4099
  {
3899
4100
  translatorBudget,
@@ -3938,17 +4139,27 @@ async function handleResponsesInner(
3938
4139
  let events: AdapterEvent[];
3939
4140
  try {
3940
4141
  const initialEvents = await activeAdapter.parseResponse(upstreamResponse, translatorBudget);
4142
+ let guardedEvents: AdapterEvent[];
3941
4143
  if (terminalGuardEnabled) {
3942
- events = [];
4144
+ guardedEvents = [];
3943
4145
  for await (const event of guardTerminalEventStream({
3944
4146
  parsed,
3945
4147
  firstEvents: (async function* () { yield* initialEvents; })(),
3946
4148
  adapterName: activeAdapter.name,
3947
4149
  maxAutoContinuations: 1,
3948
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,
3949
4160
  })) events.push(event);
3950
4161
  } else {
3951
- events = initialEvents;
4162
+ events = guardedEvents;
3952
4163
  }
3953
4164
  } finally {
3954
4165
  cleanupUpstreamAbort();