@bitkyc08/opencodex 2.89.0 → 2.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  2. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
  3. package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
  4. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +2 -2
  7. package/src/adapters/base.ts +4 -0
  8. package/src/adapters/claude-cli/adapter.ts +13 -7
  9. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  10. package/src/adapters/coding-agent/turn.ts +14 -4
  11. package/src/adapters/cursor/protobuf-request.ts +3 -0
  12. package/src/adapters/google-progress-history.ts +19 -1
  13. package/src/adapters/google.ts +28 -9
  14. package/src/adapters/image.ts +5 -3
  15. package/src/adapters/input-media-guard.ts +5 -2
  16. package/src/adapters/kiro-refusal.ts +41 -1
  17. package/src/adapters/ollama-native.ts +1 -0
  18. package/src/adapters/openai-chat/messages.ts +34 -4
  19. package/src/adapters/openai-chat/passthrough.ts +37 -4
  20. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  21. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  22. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  23. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  24. package/src/adapters/openai-chat.ts +7 -6
  25. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  26. package/src/adapters/openai-responses/passthrough.ts +5 -1
  27. package/src/adapters/openai-responses/transport.ts +13 -1
  28. package/src/claude/desktop-3p-library.ts +43 -3
  29. package/src/claude/desktop-3p.ts +20 -10
  30. package/src/claude/desktop-picker-profile.ts +18 -9
  31. package/src/claude/desktop-remote-store-io.ts +2 -2
  32. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  33. package/src/codex/account-lifecycle.ts +19 -6
  34. package/src/codex/account-store.ts +27 -8
  35. package/src/codex/account-usability.ts +4 -1
  36. package/src/codex/auth-api/account-list.ts +10 -5
  37. package/src/codex/auth-api/account-pause-group.ts +2 -22
  38. package/src/codex/auth-api/login-collision.ts +59 -0
  39. package/src/codex/auth-api/login-flow.ts +12 -7
  40. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  41. package/src/codex/auth-collision.ts +31 -4
  42. package/src/codex/auth-context.ts +48 -6
  43. package/src/codex/cli-update-plan.ts +18 -3
  44. package/src/codex/collision-identity.ts +78 -0
  45. package/src/codex/main-account-hard-lock.ts +14 -0
  46. package/src/codex/routing/cooldown-math.ts +5 -0
  47. package/src/codex/routing/failure-window.ts +61 -9
  48. package/src/codex/routing.ts +4 -3
  49. package/src/codex/shim-diagnostics.ts +2 -1
  50. package/src/codex/shim-fingerprint.ts +11 -2
  51. package/src/codex/shim-templates.ts +16 -5
  52. package/src/codex/shim-windows-refresh.ts +224 -0
  53. package/src/codex/shim.ts +45 -21
  54. package/src/combos/failover.ts +8 -1
  55. package/src/config/provider-validation.ts +7 -2
  56. package/src/generated/compatibility-version.json +168 -108
  57. package/src/generated/model-metadata.ts +1 -1
  58. package/src/images/loop.ts +2 -2
  59. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  60. package/src/lib/provider-admission-wait.ts +14 -0
  61. package/src/lib/service-secrets.ts +37 -2
  62. package/src/lib/translator-budget.ts +1 -1
  63. package/src/lib/upstream-retry.ts +15 -0
  64. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  65. package/src/oauth/account-import/parser.ts +15 -2
  66. package/src/oauth/account-import/types.ts +7 -0
  67. package/src/oauth/chatgpt-shared.ts +10 -1
  68. package/src/oauth/dsh.ts +45 -2
  69. package/src/oauth/kiro.ts +44 -1
  70. package/src/oauth/login-flow-state.ts +11 -2
  71. package/src/providers/claude-cli-usage.ts +20 -5
  72. package/src/providers/initial-model-selection.ts +2 -2
  73. package/src/providers/key-failover.ts +10 -2
  74. package/src/providers/key-store.ts +8 -3
  75. package/src/providers/mistral-transport.ts +207 -0
  76. package/src/providers/openai-sidecar.ts +5 -0
  77. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  78. package/src/providers/quota/antigravity.ts +6 -2
  79. package/src/providers/quota/failure-codes.ts +18 -0
  80. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  81. package/src/providers/quota-routing-cache.ts +1 -1
  82. package/src/providers/quota.ts +5 -1
  83. package/src/providers/registry/entries-extended.ts +6 -1
  84. package/src/providers/routed-config.ts +1 -1
  85. package/src/providers/xai-transport.ts +2 -1
  86. package/src/responses/audio.ts +46 -0
  87. package/src/responses/input-media.ts +37 -15
  88. package/src/responses/parser-content.ts +22 -27
  89. package/src/responses/reasoning-retention.ts +218 -11
  90. package/src/responses/schema.ts +3 -4
  91. package/src/responses/web-search-replay.ts +11 -2
  92. package/src/server/chat-native.ts +8 -7
  93. package/src/server/index/serve-options.ts +8 -2
  94. package/src/server/index/websocket-handler.ts +10 -0
  95. package/src/server/lifecycle.ts +17 -1
  96. package/src/server/management/oauth-account-routes.ts +2 -1
  97. package/src/server/messages-native.ts +11 -0
  98. package/src/server/request-log.ts +16 -7
  99. package/src/server/responses/adapter-continuation.ts +2 -2
  100. package/src/server/responses/adapter-dispatch.ts +2 -2
  101. package/src/server/responses/codex-ws-exchange.ts +3 -0
  102. package/src/server/responses/codex-ws-metadata.ts +44 -0
  103. package/src/server/responses/codex-ws-session.ts +21 -1
  104. package/src/server/responses/combo-stream-preflight.ts +7 -2
  105. package/src/server/responses/compact.ts +2 -0
  106. package/src/server/responses/core-auth.ts +20 -0
  107. package/src/server/responses/core-codex-account.ts +21 -14
  108. package/src/server/responses/core-combo.ts +3 -0
  109. package/src/server/responses/core-opaque-recovery.ts +20 -11
  110. package/src/server/responses/deepseek-image-files.ts +121 -0
  111. package/src/server/responses/empty-completion-guard.ts +8 -0
  112. package/src/server/responses/fetch-helpers.ts +30 -10
  113. package/src/server/responses/input-admission.ts +6 -2
  114. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  115. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  116. package/src/server/responses/passthrough-completion.ts +41 -0
  117. package/src/server/responses/passthrough-delivery.ts +2 -0
  118. package/src/server/responses/passthrough-dispatch.ts +13 -13
  119. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  120. package/src/server/responses/reasoning-archive-error.ts +21 -0
  121. package/src/server/responses/request-prepare.ts +11 -1
  122. package/src/server/responses/request-transport.ts +61 -3
  123. package/src/service/orchestration.ts +17 -0
  124. package/src/types/provider.ts +2 -0
  125. package/src/types/request.ts +16 -1
  126. package/src/types.ts +1 -0
  127. package/src/web-search/loop.ts +2 -2
  128. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  129. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -31,7 +31,7 @@ import {
31
31
  applyUpstreamRecoveryInit,
32
32
  fetchWithResetRetry,
33
33
  fetchWithTransientRetry,
34
- isNonReplayableResponse,
34
+ isNonReplayableResponse, isSameTargetRetryHold,
35
35
  isReplayRefusalCode,
36
36
  isReplayRefusalResponse,
37
37
  prepareSameTarget429Wait,
@@ -45,10 +45,7 @@ import {
45
45
  UPSTREAM_RESET_REPLAY_REFUSED_CODE,
46
46
  wrapWithZeroOutputRefetch,
47
47
  } from "../lib/upstream-retry";
48
- import {
49
- isTranslatorBudgetExceededError,
50
- type TranslatorBudget,
51
- } from "../lib/translator-budget";
48
+ import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
52
49
  import { authorizeResendForRecovery } from "../lib/request-resend-gate";
53
50
  import { ambiguousResendAllowanceFor, selfContainedChatBody } from "./responses/reset-replay";
54
51
  import {
@@ -88,6 +85,7 @@ import { workflowRefusalResponse } from "./workflow-refusal";
88
85
  import type { ComboProtocolSource } from "./responses/core-combo-native";
89
86
  import type { ProtocolEnvelope } from "../protocols/envelope";
90
87
  import { nativeChatReplayScope, bindNativeChatReplayScope, nativeChatReasoningCapture } from "./chat-native-reasoning";
88
+ import { resolveMistralTransport } from "../providers/mistral-transport";
91
89
 
92
90
  export { isNativeChatRouteEligible, nativeChatDeclineReason } from "./chat-native-eligibility";
93
91
 
@@ -335,7 +333,8 @@ export async function runNativeChatAttempt(
335
333
  refusal.headers.set("Retry-After", String(error.retryAfterSeconds));
336
334
  return refusal;
337
335
  }
338
- let activeProvider: OcxProviderConfig = route.provider;
336
+ // Key selection re-resolves the transport; a single-key route still needs canonical Mistral pacing.
337
+ let activeProvider: OcxProviderConfig = resolveMistralTransport(route.provider);
339
338
  stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
340
339
  const spendTracker = sendBudget ? undefined : attachRequestSpendTracker(req, logCtx);
341
340
  const physicalBudget = sendBudget ?? createInferenceSendBudget(req, logCtx);
@@ -365,6 +364,8 @@ export async function runNativeChatAttempt(
365
364
  config.fastMode,
366
365
  route.providerName,
367
366
  replayScope,
367
+ undefined,
368
+ translatorBudget,
368
369
  );
369
370
  observeAdvisorNativeReasoning(req.headers, logCtx, request.body);
370
371
  return request;
@@ -610,7 +611,7 @@ export async function runNativeChatAttempt(
610
611
  // limit: waiting and re-sending here is exactly the duplicate inference the refusal
611
612
  // exists to stop. It kept the same shape under the old 502 only because 502 never
612
613
  // matched this branch.
613
- && !isNonReplayableResponse(response)
614
+ && !isNonReplayableResponse(response) && !isSameTargetRetryHold(response)
614
615
  && retryPolicy
615
616
  && retries < retryPolicy.attempts
616
617
  && transientSendAvailable()
@@ -206,6 +206,7 @@ import type { WorkflowRefusalLog } from "../workflow-refusal";
206
206
  import { readyProtocolMetadata } from "../../remote/protocol";
207
207
  import { modelCapabilityFields } from "../models-capabilities";
208
208
  import { createWebsocketHandler } from "./websocket-handler";
209
+ import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
209
210
  import { withGrokSessionIdentity } from "../../grok/session-identity";
210
211
  import { withCallerSessionIdentity } from "../caller-session-identity";
211
212
 
@@ -1347,8 +1348,9 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1347
1348
  response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission, {
1348
1349
  onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer),
1349
1350
  });
1350
- } catch {
1351
- response = formatErrorResponse(500, "server_error", "Unexpected compact request failure");
1351
+ } catch (error) {
1352
+ response = reasoningArchiveErrorResponse(error)
1353
+ ?? formatErrorResponse(500, "server_error", "Unexpected compact request failure");
1352
1354
  }
1353
1355
  addFinalRequestLog(requestId, start, logCtx, response.status,
1354
1356
  response.status === 499 ? { closeReason: "client_cancel" } : undefined);
@@ -1519,6 +1521,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
1519
1521
  },
1520
1522
  });
1521
1523
  } catch (error) {
1524
+ const archiveError = reasoningArchiveErrorResponse(error);
1525
+ if (archiveError) return withRequestLogId(
1526
+ withCors(responseWithDeferredRequestLog(archiveError, requestId, start, logCtx), req, policy), requestId,
1527
+ );
1522
1528
  // A bounded upstream body can fail before handleResponses returns a client response.
1523
1529
  // Finalize before rethrow so accounting observes the physical send while the caller
1524
1530
  // retains the existing reset/rejection instead of receiving a synthesized response.
@@ -2,6 +2,7 @@ import { nativeSteeringUnavailableReason, nativeResponseControlMode, type Native
2
2
  import { NativeInjectionChannel } from "../responses/native-injection";
3
3
  import { NativeSteeringChannel, NativeSteeringError } from "../responses/native-steering";
4
4
  import { createNativeSteeringLogObserver } from "../responses/native-steering-log";
5
+ import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
5
6
  import type { Server, ServerWebSocket } from "bun";
6
7
  import {
7
8
  LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
@@ -396,6 +397,15 @@ export function createWebsocketHandler(
396
397
  }));
397
398
  return;
398
399
  }
400
+ // A local archive refusal happens before any send. Carry the same sanitized 503
401
+ // body and stable code as HTTP so no filesystem path reaches the client frame.
402
+ const archiveError = reasoningArchiveErrorResponse(err);
403
+ if (archiveError) {
404
+ finalizeLog(archiveError.status);
405
+ const body = await archiveError.json() as { error: Record<string, unknown> };
406
+ sendJsonFrame(ws, buildWsErrorFrame(archiveError.status, body.error));
407
+ return;
408
+ }
399
409
  finalizeLog(502);
400
410
  sendJsonFrame(ws, buildWsErrorFrame(502, {
401
411
  type: "proxy_error",
@@ -476,13 +476,25 @@ export function trackStreamLifetime(
476
476
  registerTurn(ac, lease);
477
477
  const reader = body.getReader();
478
478
  let closed = false;
479
+ let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
480
+ // A drain aborts the turn's controller; many callers pass a fresh one nothing else listens to,
481
+ // so the tracked stream itself has to stop.
482
+ const onAbort = () => {
483
+ if (closed) return;
484
+ // Stop the stream before the caller's onDone runs, so a throwing callback cannot leave it open.
485
+ reader.cancel(ac.signal.reason).catch(() => {});
486
+ try { streamController?.error(ac.signal.reason); } catch { /* already closed */ }
487
+ finish();
488
+ };
479
489
  const finish = () => {
480
490
  if (closed) return;
481
491
  closed = true;
492
+ ac.signal.removeEventListener("abort", onAbort);
482
493
  unregisterTurn(ac);
483
494
  onDone?.();
484
495
  };
485
- return new ReadableStream<Uint8Array>({
496
+ const tracked = new ReadableStream<Uint8Array>({
497
+ start(controller) { streamController = controller; },
486
498
  async pull(controller) {
487
499
  try {
488
500
  const { done, value } = await reader.read();
@@ -499,6 +511,10 @@ export function trackStreamLifetime(
499
511
  reader.cancel(reason).catch(() => {});
500
512
  },
501
513
  });
514
+ // After registerTurn, so the lease's own abort listener (release the turn) runs first. A
515
+ // controller registerTurn already aborted (a settled lease) keeps its previous behaviour.
516
+ ac.signal.addEventListener("abort", onAbort, { once: true });
517
+ return tracked;
502
518
  }
503
519
 
504
520
  export async function drainAndShutdown(
@@ -374,7 +374,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
374
374
  // Without a flow id this cancels the provider's newest in-flight login, which is what the
375
375
  // single-user GUI has always meant by "Cancel". A host running concurrent code logins for
376
376
  // several users passes the flowId it was given at /api/oauth/login.
377
- const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined);
377
+ const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined,
378
+ { dismissSettledFailure: true });
378
379
  return jsonResponse({ ok: true, cancelled });
379
380
  }
380
381
 
@@ -849,6 +849,17 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
849
849
  }
850
850
  releaseRetainedRequest();
851
851
 
852
+ // The last pool account refused on spent quota and rotation found no replacement: answer the
853
+ // usage limit with its reset (#6991), as the bridge lane does, not the upstream "try again later".
854
+ const poolUsageLimit = response.status === 429 && oauthBinding
855
+ ? anthropicRoutingFor(nativeInstance!).anthropicPoolUsageLimitAfterRefusal(Date.now(), oauthBinding.routeDecision, route.modelId)
856
+ : null;
857
+ if (poolUsageLimit) {
858
+ discard(response);
859
+ cleanupAbort();
860
+ logCtx.errorCode = "usage_limit_reached";
861
+ return retainUpstreamMessagesRequestId(localOAuthFailure(poolUsageLimit)!, response.headers);
862
+ }
852
863
  if (!response.ok) {
853
864
  let bodyText = "";
854
865
  try {
@@ -1720,8 +1720,10 @@ export function noteProviderAttemptSend(
1720
1720
  if (attempt && attempt.sendCount > 0 && attemptAccountChanged(previous, next, attempt.provider, logCtx.provider)) {
1721
1721
  // An input estimate is not evidence that a failed send used that many tokens.
1722
1722
  delete attempt.inputTokenEstimate;
1723
+ // An account-scoped recovery kind names the status the sealed account answered.
1723
1724
  finishRequestAttempt(attempt, attempt.status >= 100 ? attempt.status
1724
- : recovery === "key-401" ? 401 : recovery?.includes("429") ? 429 : 502,
1725
+ : recovery === "key-401" || recovery === "oauth-401" ? 401 : recovery === "oauth-account-403" ? 403
1726
+ : recovery === "oauth-account-402" ? 402 : recovery?.includes("429") ? 429 : 502,
1725
1727
  Date.now() - (logCtx.activeAttemptStartedAt ?? Date.now()), attempt.usage);
1726
1728
  // This attempt is being sealed because a NAMED recovery rejected it, so the recovery kind
1727
1729
  // is direct evidence here rather than an inference from history. Without this the sealed
@@ -1850,16 +1852,20 @@ export function finishRequestAttempt(
1850
1852
  export function aggregateAttemptUsage(
1851
1853
  attempts: readonly PersistedUsageAttempt[],
1852
1854
  ): FinalizedUsageResult {
1853
- const status: UsageStatus = attempts.length > 0
1854
- && attempts.every(attempt => attempt.usageStatus === "unsupported")
1855
+ // A usage-less attempt that never reached upstream spent nothing (summary.ts `spentNothing`),
1856
+ // so it cannot leave the row's usage unknown.
1857
+ const measured = attempts.filter(attempt => attempt.usage
1858
+ || (attempt.sendCount !== 0 && attempt.locallyAnswered !== true));
1859
+ const status: UsageStatus = measured.length > 0
1860
+ && measured.every(attempt => attempt.usageStatus === "unsupported")
1855
1861
  ? "unsupported"
1856
- : attempts.some(attempt => (
1862
+ : measured.some(attempt => (
1857
1863
  attempt.usageStatus === "unreported" || attempt.usageStatus === "unsupported"
1858
1864
  ))
1859
1865
  ? "unreported"
1860
- : attempts.some(attempt => attempt.usageStatus === "estimated")
1866
+ : measured.some(attempt => attempt.usageStatus === "estimated")
1861
1867
  ? "estimated"
1862
- : attempts.length > 0
1868
+ : measured.length > 0
1863
1869
  ? "reported"
1864
1870
  : "unreported";
1865
1871
 
@@ -1876,7 +1882,10 @@ export function aggregateAttemptUsage(
1876
1882
  return present.length > 0 ? present.reduce((sum, value) => sum + value, 0) : undefined;
1877
1883
  };
1878
1884
  const cachedInputTokens = sumOptional("cachedInputTokens");
1879
- const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
1885
+ // Readers take `cacheReadInputTokens ?? cachedInputTokens` (usage/cost.ts), so once any attempt
1886
+ // reports the read field, an attempt that only reports `cachedInputTokens` must count in it too.
1887
+ const cacheReadInputTokens = sumOptional("cacheReadInputTokens") === undefined ? undefined
1888
+ : usages.reduce((sum, usage) => sum + (usage.cacheReadInputTokens ?? usage.cachedInputTokens ?? 0), 0);
1880
1889
  const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
1881
1890
  const cacheCreation1hInputTokens = sumOptional("cacheCreation1hInputTokens");
1882
1891
  const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
@@ -28,7 +28,7 @@ import {
28
28
  fetchWithTransientRetry,
29
29
  fetchWithResetRetry,
30
30
  applyUpstreamRecoveryInit,
31
- isNonReplayableResponse,
31
+ isNonReplayableResponse, isSameTargetRetryHold,
32
32
  prepareSameTarget429Wait,
33
33
  SendBudgetExhaustedError,
34
34
  } from "../../lib/upstream-retry";
@@ -349,7 +349,7 @@ export function createAdapterContinuations(
349
349
  && !kiroReplaySetupFailed
350
350
  // A synthesized replay refusal is not a rate limit; replaying the continuation on
351
351
  // it would re-send a turn whose first send may already have been processed.
352
- && !isNonReplayableResponse(response)
352
+ && !isNonReplayableResponse(response) && !isSameTargetRetryHold(response)
353
353
  && rateLimitPolicy !== null
354
354
  && adapterExchange.rateLimitRetries < rateLimitPolicy.attempts
355
355
  // The main recovery loop and the passthrough ladder both consult the shared remainder
@@ -9,7 +9,7 @@ import { rotateAnthropicAccountOnResponseForInstance } from "../../oauth/anthrop
9
9
  import { authorizeResendForRecovery } from "../../lib/request-resend-gate";
10
10
  import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay";
11
11
  import { transientSendCapFor } from "./request-send-budget";
12
- import { isNonReplayableResponse } from "../../lib/upstream-retry";
12
+ import { isNonReplayableResponse, isSameTargetRetryHold } from "../../lib/upstream-retry";
13
13
  import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error";
14
14
  import { isLocalUpstream } from "../../lib/local-upstream";
15
15
  import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
@@ -1239,7 +1239,7 @@ export async function prepareAdapterExchange(
1239
1239
  // replay is lossless. Runs before key failover so "primary-first" setups keep the same
1240
1240
  // key on rate-limit blips; only after the attempts are exhausted does failover run.
1241
1241
  while (
1242
- upstreamResponse.status === 429
1242
+ upstreamResponse.status === 429 && !isSameTargetRetryHold(upstreamResponse)
1243
1243
  && rateLimitPolicy !== null
1244
1244
  && rateLimitRetries < rateLimitPolicy.attempts
1245
1245
  && !sendBudgetExhausted()
@@ -458,6 +458,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
458
458
  return;
459
459
  }
460
460
  if (terminal || settledPreOpen || signal?.aborted) return;
461
+ // New sockets have just validated their upgrade; retained sockets carry the
462
+ // same connection signal into a fresh exchange, never its predecessor's frames.
463
+ metadata?.seed(session.upgradeHeaders());
461
464
  sent = true;
462
465
  try {
463
466
  if (nativeControl) {
@@ -6,11 +6,48 @@ export const CODEX_WS_METADATA_MAX_HEADERS = 128;
6
6
  export const CODEX_WS_METADATA_MAX_VALUE_BYTES = 4096;
7
7
 
8
8
  export type CodexWsQuotaObserver = (headers: Headers) => void;
9
+ const REASONING_INCLUDED_HEADER = "x-reasoning-included";
10
+ const HTTP_HEADER_TOKEN = /^[!#$%&'*+.^_`|~0-9a-z-]+$/i;
9
11
 
10
12
  function record(value: unknown): value is Record<string, unknown> {
11
13
  return value !== null && typeof value === "object" && !Array.isArray(value);
12
14
  }
13
15
 
16
+ /** Bun emits this payload before validating the upgrade; the session must wait for open. */
17
+ export function readCodexWsUpgradeHeaders(payload: unknown): Headers {
18
+ try {
19
+ if (!record(payload) || payload.statusCode !== 101 || !Array.isArray(payload.rawHeaders)
20
+ || payload.rawHeaders.length % 2 !== 0 || payload.rawHeaders.length > CODEX_WS_METADATA_MAX_HEADERS * 2) return new Headers();
21
+ const headers = new Headers();
22
+ let bytes = 0;
23
+ let connectionNominated = false;
24
+ for (let i = 0; i < payload.rawHeaders.length; i += 2) {
25
+ const name = payload.rawHeaders[i];
26
+ const value = payload.rawHeaders[i + 1];
27
+ if (typeof name !== "string" || typeof value !== "string"
28
+ || name.length + value.length > CODEX_WS_METADATA_MAX_BYTES - bytes || !HTTP_HEADER_TOKEN.test(name)
29
+ || /[\r\n\0]/.test(value)) return new Headers();
30
+ bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
31
+ if (bytes > CODEX_WS_METADATA_MAX_BYTES) return new Headers();
32
+ const lower = name.toLowerCase();
33
+ if (lower === "connection" && value.split(",").some(token => token.trim().toLowerCase() === REASONING_INCLUDED_HEADER)) {
34
+ connectionNominated = true;
35
+ }
36
+ // Other allowlisted response headers (quota, request id, turn state, model)
37
+ // describe a response, not a reusable connection. Never retain them here.
38
+ if (lower === REASONING_INCLUDED_HEADER) {
39
+ if (Buffer.byteLength(value) > CODEX_WS_METADATA_MAX_VALUE_BYTES) return new Headers();
40
+ headers.append(lower, value);
41
+ if (Buffer.byteLength(headers.get(lower)!) > CODEX_WS_METADATA_MAX_VALUE_BYTES) return new Headers();
42
+ }
43
+ }
44
+ return connectionNominated ? new Headers() : headers;
45
+ } catch {
46
+ // Missing/unsupported or malformed optional metadata cannot authorize a retry.
47
+ return new Headers();
48
+ }
49
+ }
50
+
14
51
  function finiteNonnegative(value: unknown): value is number {
15
52
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
16
53
  }
@@ -91,6 +128,13 @@ export class CodexWsMetadata {
91
128
 
92
129
  snapshot(): Headers { return new Headers(this.headers); }
93
130
 
131
+ /** Seed only the connection-level inclusion signal; per-response frames still win. */
132
+ seed(headers: Headers): void {
133
+ if (this.ended || this.committed || this.headers.has(REASONING_INCLUDED_HEADER)) return;
134
+ const value = headers.get(REASONING_INCLUDED_HEADER);
135
+ if (value !== null) setMetadataHeader(this.headers, REASONING_INCLUDED_HEADER, value);
136
+ }
137
+
94
138
  private publishQuota(headers: Headers): void {
95
139
  // Observation is auxiliary bookkeeping; a consumer exception cannot turn
96
140
  // a valid provider frame into a retryable transport failure.
@@ -1,3 +1,5 @@
1
+ import { readCodexWsUpgradeHeaders } from "./codex-ws-metadata";
2
+
1
3
  export const MAX_CODEX_WS_SESSION_EXCHANGES = 32;
2
4
 
3
5
  /** Owns one physical socket; request listeners belong to the exchange, not this object. */
@@ -8,10 +10,13 @@ export class CodexWsSession {
8
10
  busy = false;
9
11
  private owner?: (reason: Error) => void;
10
12
  private readonly completedIds = new Set<string>();
13
+ private pendingUpgradeHeaders?: Headers;
14
+ private connectionHeaders?: Headers;
11
15
 
12
16
  constructor(url: string, headers: Record<string, string>, readonly retainable = false,
13
17
  private readonly changed: () => void = () => {}, proxy?: string) {
14
18
  this.socket = new WebSocket(url, { headers, ...(proxy ? { proxy } : {}) } as unknown as string[]);
19
+ this.socket.addEventListener("handshake", this.onHandshake);
15
20
  this.socket.addEventListener("open", this.onOpen);
16
21
  this.socket.addEventListener("message", this.onIdleMessage);
17
22
  this.socket.addEventListener("close", this.onClose);
@@ -20,6 +25,7 @@ export class CodexWsSession {
20
25
 
21
26
  get reused(): boolean { return this.completedIds.size > 0; }
22
27
  hasCompleted(id: string): boolean { return this.completedIds.has(id); }
28
+ upgradeHeaders(): Headers { return new Headers(this.opened && !this.closed ? this.connectionHeaders : undefined); }
23
29
 
24
30
  reserve(): boolean {
25
31
  if (this.closed || this.busy || (this.opened && this.socket.readyState !== undefined && this.socket.readyState !== 1)) return false;
@@ -71,7 +77,18 @@ export class CodexWsSession {
71
77
  }
72
78
  }
73
79
 
74
- private onOpen = (): void => { this.opened = true; };
80
+ private onHandshake = (event: Event): void => {
81
+ if (this.opened || this.closed) return;
82
+ this.pendingUpgradeHeaders = readCodexWsUpgradeHeaders((event as MessageEvent<unknown>).data);
83
+ };
84
+ private onOpen = (): void => {
85
+ if (this.closed) return;
86
+ // A 101 payload alone is not proof that Bun accepted the handshake.
87
+ this.connectionHeaders = this.pendingUpgradeHeaders;
88
+ this.pendingUpgradeHeaders = undefined;
89
+ this.socket.removeEventListener("handshake", this.onHandshake);
90
+ this.opened = true;
91
+ };
75
92
  private onIdleMessage = (): void => {
76
93
  if (!this.busy) this.dispose(new Error("codex websocket received unsolicited idle data"));
77
94
  };
@@ -85,6 +102,9 @@ export class CodexWsSession {
85
102
  // The active exchange's close listener retains pre-send fallback semantics.
86
103
  };
87
104
  private detach(): void {
105
+ this.pendingUpgradeHeaders = undefined;
106
+ this.connectionHeaders = undefined;
107
+ this.socket.removeEventListener("handshake", this.onHandshake);
88
108
  this.socket.removeEventListener("open", this.onOpen);
89
109
  this.socket.removeEventListener("message", this.onIdleMessage);
90
110
  this.socket.removeEventListener("close", this.onClose);
@@ -224,8 +224,13 @@ function failedTerminalResponse(
224
224
  : topLevelError && typeof topLevelError === "object" && !Array.isArray(topLevelError)
225
225
  ? topLevelError as Record<string, unknown>
226
226
  : {
227
- type: "upstream_error",
228
- code: "upstream_server_error",
227
+ // A caller-validated flat error can carry its identity beside type:error.
228
+ // Retain it when that caller established a 400, rather than discarding the
229
+ // rejection code before the single-shot opaque-state recovery sees it.
230
+ type: terminalPayload.type === "error" && logCtx.terminalHttpStatus === 400
231
+ ? "invalid_request_error" : "upstream_error",
232
+ code: terminalPayload.type === "error" && logCtx.terminalHttpStatus === 400
233
+ ? (typeof terminalPayload.code === "string" ? terminalPayload.code : null) : "upstream_server_error",
229
234
  message: typeof terminalPayload.message === "string"
230
235
  ? terminalPayload.message
231
236
  : logCtx.upstreamError ?? "Provider stream failed before producing output",
@@ -88,6 +88,7 @@ import {
88
88
  recordCodexUpstreamOutcome,
89
89
  type CodexUpstreamOutcome,
90
90
  } from "../../codex/routing";
91
+ import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
91
92
  import {
92
93
  applyAccountChangeConversationStateScrub,
93
94
  conversationStateBindingFromAuth,
@@ -1062,6 +1063,7 @@ export async function handleResponsesCompact(
1062
1063
  if ((outcome === 401 || outcome === 403) && upstream && wasPermanentAccountRefusal(upstream)) return;
1063
1064
  recordCodexUpstreamOutcome(config, ctx.accountId, outcome, {
1064
1065
  ...meta,
1066
+ ...codexFailureWindowOutcomeMeta(ctx),
1065
1067
  threadId: ctx.kind === "pool" || ctx.kind === "main-pool" ? ctx.affinityKey : undefined,
1066
1068
  fixedAccount: ctx.fixedAccount,
1067
1069
  modelId: selectedModelId,
@@ -2,6 +2,7 @@ import type { OcxProviderConfig, OcxConfig } from "../../types";
2
2
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
3
3
  import type { CodexAuthContext } from "../../codex/auth-context";
4
4
  import type { RouteResult } from "../../router";
5
+ import { routeConcreteModel } from "../../router";
5
6
  import type { HandleResponsesOptions } from "./core-options";
6
7
  import {
7
8
  hasForwardableCodexBearer,
@@ -24,6 +25,7 @@ import {
24
25
  CodexAuthContextError,
25
26
  applyCodexAuthContextToProvider,
26
27
  stripCodexRuntimeProviderFields,
28
+ mainAccountHardLockCallerProof,
27
29
  } from "../../codex/auth-context";
28
30
  import { codexAccountSelectionForTurn, tryClaimNativeMainProfileForTurn } from "../lifecycle";
29
31
  import { isNativeMainTrafficBlocked } from "../../codex/native-profile-startup";
@@ -590,3 +592,21 @@ export async function refreshNativeMainForwardAuth(args: {
590
592
  }) ?? nativeMainRefreshFailureResponse(error) };
591
593
  }
592
594
  }
595
+
596
+ /** Inspect the original credential domain, before fallback changes the route or drops headers. */
597
+ export function mainHardLockFallbackCallerCheck(
598
+ req: Request, config: OcxConfig, options: HandleResponsesOptions,
599
+ ): (sourceModel: string) => Promise<(() => boolean) | null> {
600
+ return async sourceModel => {
601
+ let sourceRoute: ReturnType<typeof routeConcreteModel>;
602
+ try { sourceRoute = routeConcreteModel(config, sourceModel); }
603
+ // This preview cannot replace the normal routing phase's existing error response.
604
+ catch { return null; }
605
+ const headers = codexRouteCredentialDomainHeaders(req, sourceRoute, options, false, config);
606
+ const { substituteMainCredential } = codexRouteCredentialOwnership(headers, config, sourceRoute, options);
607
+ return mainAccountHardLockCallerProof(headers, options.codexAuthPolicy ?? config, {
608
+ substituteMainCredentialForDirect: substituteMainCredential,
609
+ signal: options.abortSignal ?? req.signal,
610
+ });
611
+ };
612
+ }
@@ -11,6 +11,7 @@ import { claimMainQuotaDispatchForWs, isMainQuotaDispatchLive, renewMainQuotaDis
11
11
  import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
12
12
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
13
13
  import { isCodexAccountGenerationLive } from "../../codex/account-store";
14
+ import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
14
15
  import { applyAccountQuotaFromUpstreamHeaders as applyCapturedCodexQuota } from "../../codex/quota";
15
16
  import type { RouteResult } from "../../router";
16
17
  import {
@@ -93,20 +94,19 @@ export function sidecarOutcomeRecorder(
93
94
  config: OcxConfig,
94
95
  authCtx: CodexAuthContext,
95
96
  ): ((outcome: CodexUpstreamOutcome) => void) | undefined {
96
- return authCtx.kind === "pool" || authCtx.kind === "main-pool"
97
- ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
98
- threadId: authCtx.affinityKey,
99
- fixedAccount: authCtx.fixedAccount,
100
- probeLeaseId: authCtx.probeLeaseId,
101
- probeQuotaScope: authCtx.probeQuotaScope,
102
- writerGeneration: authCtx.writerGeneration,
103
- // A vision or web-search sidecar can return 401/403, and that is evidence about the exact
104
- // stored credential it used. Without the generation it becomes an account-wide quarantine
105
- // that a replacement inherits (#2892 gap 4). `main-pool` has no stored-record generation, so
106
- // it keeps the unfenced account-wide semantics.
107
- ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}),
108
- })
109
- : undefined;
97
+ if (authCtx.kind !== "pool" && authCtx.kind !== "main-pool") return undefined;
98
+ const windowMeta = codexFailureWindowOutcomeMeta(authCtx);
99
+ return outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
100
+ ...windowMeta,
101
+ threadId: authCtx.affinityKey,
102
+ fixedAccount: authCtx.fixedAccount,
103
+ probeLeaseId: authCtx.probeLeaseId,
104
+ probeQuotaScope: authCtx.probeQuotaScope,
105
+ writerGeneration: authCtx.writerGeneration,
106
+ // Quarantine retains its stored-credential fence. main-pool has no stored-record
107
+ // generation; its separate dispatch proof above fences only failure-window samples.
108
+ ...(authCtx.kind === "pool" ? { credentialGeneration: authCtx.generation } : {}),
109
+ });
110
110
  }
111
111
 
112
112
 
@@ -148,6 +148,7 @@ export function recordCodexPreOutputRefusalOutcome(
148
148
  if (!usesCodexForwardPoolAuth(authCtx, provider) || wasPermanentAccountRefusal(response)) return;
149
149
  noteCodexWsPreOutputRefusalOutcome(response);
150
150
  recordCodexUpstreamOutcome(config, authCtx.accountId, response.status, {
151
+ ...codexFailureWindowOutcomeMeta(authCtx),
151
152
  threadId: authCtx.affinityKey,
152
153
  fixedAccount: authCtx.fixedAccount,
153
154
  modelId,
@@ -676,6 +677,7 @@ export async function retryCodexPoolOnAlternateAccount(
676
677
  if (codexWsPreOutputRefusalOutcomeRecorded(firstResponse)) return;
677
678
  noteCodexWsPreOutputRefusalOutcome(firstResponse);
678
679
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
680
+ ...codexFailureWindowOutcomeMeta(firstAuthCtx),
679
681
  threadId: firstAuthCtx.affinityKey,
680
682
  fixedAccount: firstAuthCtx.fixedAccount,
681
683
  modelId: route.modelId,
@@ -693,6 +695,7 @@ export async function retryCodexPoolOnAlternateAccount(
693
695
  if (outcomeStatus === firstResponse.status || (outcomeStatus !== 429 && outcomeStatus !== 402)) return;
694
696
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
695
697
  ...codexQuotaOutcomeMeta(firstResponse),
698
+ ...codexFailureWindowOutcomeMeta(firstAuthCtx),
696
699
  threadId: firstAuthCtx.affinityKey,
697
700
  modelId: route.modelId,
698
701
  probeLeaseId: codexProbeLeaseId(firstAuthCtx),
@@ -862,6 +865,7 @@ export async function retryCodexPoolOnAlternateAccount(
862
865
  }
863
866
  recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
864
867
  ...quotaMeta,
868
+ ...codexFailureWindowOutcomeMeta(firstAuthCtx),
865
869
  threadId: firstAuthCtx.affinityKey,
866
870
  modelId: route.modelId,
867
871
  probeLeaseId: codexProbeLeaseId(firstAuthCtx),
@@ -1149,6 +1153,7 @@ export function codexForwardTerminalOutcomeRecorder(
1149
1153
  logCtx?: RequestLogContext,
1150
1154
  ): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
1151
1155
  if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
1156
+ const windowMeta = codexFailureWindowOutcomeMeta(authCtx);
1152
1157
  return (status, httpStatusOverride) => {
1153
1158
  const quotaStatus = [httpStatusOverride, logCtx?.terminalHttpStatus]
1154
1159
  .find(value => value === 429 || value === 402);
@@ -1157,6 +1162,7 @@ export function codexForwardTerminalOutcomeRecorder(
1157
1162
  // request. Don't penalize account health; record success to clear any
1158
1163
  // prior soft-avoid so a healthy account isn't stuck avoided.
1159
1164
  recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
1165
+ ...windowMeta,
1160
1166
  threadId: authCtx.affinityKey,
1161
1167
  fixedAccount: authCtx.fixedAccount,
1162
1168
  modelId,
@@ -1180,6 +1186,7 @@ export function codexForwardTerminalOutcomeRecorder(
1180
1186
  ? 200
1181
1187
  : (quotaStatus ?? httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
1182
1188
  recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
1189
+ ...windowMeta,
1183
1190
  threadId: authCtx.affinityKey,
1184
1191
  fixedAccount: authCtx.fixedAccount,
1185
1192
  modelId,
@@ -1165,6 +1165,9 @@ export async function executeComboResponses(
1165
1165
  : comboFailureDecision(failure.response.status, failure.classificationText, {
1166
1166
  code: failure.upstreamCode,
1167
1167
  codexModelRefusal: failure.codexModelRefusal,
1168
+ // The adapter the failed leg actually resolved: a configured `kiro` provider can route a
1169
+ // model through another wire via `modelAdapters`, and an unresolved leg grants no hop.
1170
+ failedAdapter: childLog.providerAdapter,
1168
1171
  });
1169
1172
  const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true;
1170
1173
  // Local byte admission has its own diagnostic; do not relabel it as an upstream refusal.
@@ -259,8 +259,15 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
259
259
  }
260
260
  }
261
261
 
262
- // The flat stream-error envelope carries type/message at the top level rather than under
263
- // an error object; the same anchored identity applies there.
262
+ // Flat SSE error events carry their code/message beside type:error, without a nested
263
+ // error object. Match only the same dedicated code or exact verification identity.
264
+ if ((record.error === undefined || record.error === null)
265
+ && (record.type === "error" || record.type === "invalid_request_error")
266
+ && (record.code === "invalid_encrypted_content" || ((record.code === null || record.code === undefined)
267
+ && typeof record.message === "string" && record.message.startsWith("The encrypted content ")
268
+ && record.message.endsWith(" could not be verified. Reason: Encrypted content could not be decrypted or parsed.")))) return true;
269
+
270
+ // The caller-mismatch identity also arrives as a flat typed error.
264
271
  if (
265
272
  record.type === "invalid_request_error"
266
273
  && typeof record.message === "string"
@@ -277,14 +284,14 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
277
284
 
278
285
 
279
286
  /**
280
- * Opted-in native reasoning retention forwards ciphertext another model or account minted. ChatGPT
281
- * can refuse it inside a 200 stream -- a Codex WebSocket `error` frame or `response.failed` --
282
- * rather than as an HTTP 4xx, so the passthrough inspects the zero-output prelude of exactly these
283
- * requests. Ordinary turns keep the function-output-only inspection.
287
+ * Opaque state can be refused inside a 200 stream rather than as an HTTP 4xx. The passthrough
288
+ * inspects its bounded zero-output prelude even when a restored thread has no serving record;
289
+ * successful native ciphertext remains unchanged and blobless turns do not gain inspection.
284
290
  */
285
291
  export function retainedReasoningStreamCandidate(parsed: OcxParsedRequest, outboundBody: string | undefined): boolean {
286
- return parsed._nativeReasoningRetainedAcrossRoute === true
287
- && parsed._stripReasoningEncryptedContent !== true
292
+ // A restored thread has no process-local serving record. Inspect its actual outbound
293
+ // opaque state too; only an explicit zero-output rejection can authorize the rebuild.
294
+ return parsed._stripReasoningEncryptedContent !== true
288
295
  && outboundResponsesBodyCarriesOpaqueBlob(outboundBody);
289
296
  }
290
297
 
@@ -310,7 +317,7 @@ export function retainedReasoningStreamRejection(
310
317
 
311
318
 
312
319
  /**
313
- * Whether an upstream Responses 4xx authoritatively rejected opaque replay state.
320
+ * Whether an upstream Responses 4xx or explicitly identified 502 rejected opaque replay state.
314
321
  *
315
322
  * The outbound-body check is intentional: the inbound transcript may contain a proxy envelope or
316
323
  * compaction blob that the adapter already lowered, in which case a replay would be byte-identical.
@@ -329,8 +336,10 @@ export function shouldAttemptOpaqueBlobRecovery(args: {
329
336
  const acceptedStatus = (args.status >= 400 && args.status < 500)
330
337
  || (
331
338
  args.status === 502
332
- && outboundResponsesBodyCarriesEncryptedFunctionOutput(args.outboundBody)
333
- && isEncryptedFunctionOutputRejection(args.errorBody)
339
+ // Gateways can project a reasoning-only rejection as 502. The existing strict
340
+ // identity classifier below still excludes generic gateway/transport failures.
341
+ && (!isEncryptedFunctionOutputRejection(args.errorBody)
342
+ || outboundResponsesBodyCarriesEncryptedFunctionOutput(args.outboundBody))
334
343
  );
335
344
  return acceptedStatus
336
345
  && adapterSpeaksResponsesWire(args.adapterName)