@bitkyc08/opencodex 2.68.0 → 2.69.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 (175) hide show
  1. package/bin/ocx.mjs +25 -17
  2. package/gui/dist/assets/App-DDbgOTh6.js +51 -0
  3. package/gui/dist/assets/{Tray-Bn8WhKBH.js → Tray-uqKRqfvw.js} +1 -1
  4. package/gui/dist/assets/index-BF9QnKO_.css +1 -0
  5. package/gui/dist/assets/index-CuaBJkG4.js +86 -0
  6. package/gui/dist/assets/{quota-summary-DL-bNuNL.js → quota-summary-COppQSVq.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/factory-droid.svg +8 -0
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic-output-schema.ts +109 -11
  11. package/src/adapters/coding-agent/protocol.ts +106 -11
  12. package/src/adapters/coding-agent/turn.ts +23 -18
  13. package/src/adapters/google-errors.ts +16 -1
  14. package/src/adapters/google-http.ts +14 -0
  15. package/src/adapters/openai-chat/passthrough.ts +2 -0
  16. package/src/adapters/openai-chat/summary-budget.ts +72 -0
  17. package/src/adapters/openai-chat/tool-call-id-remint.ts +11 -2
  18. package/src/adapters/openai-chat.ts +6 -9
  19. package/src/bridge/response-json.ts +3 -1
  20. package/src/bridge/sse.ts +3 -1
  21. package/src/claude/desktop-picker.ts +6 -2
  22. package/src/claude/desktop-system-proxy.ts +155 -0
  23. package/src/claude/inbound-model-options.ts +10 -2
  24. package/src/claude/inbound.ts +1 -1
  25. package/src/claude/intercept/picker-ca-cleanup.ts +31 -0
  26. package/src/claude/intercept/picker-ca.ts +283 -83
  27. package/src/claude/intercept/picker-models.ts +4 -1
  28. package/src/claude/intercept/picker-trust.ts +15 -2
  29. package/src/claude/intercept/runtime.ts +99 -32
  30. package/src/cli/account-api.ts +5 -2
  31. package/src/cli/account-extended.ts +47 -11
  32. package/src/cli/account-target.ts +12 -7
  33. package/src/cli/account.ts +47 -3
  34. package/src/cli/capabilities.ts +33 -7
  35. package/src/cli/claude-agent-startup-sync.ts +2 -0
  36. package/src/cli/claude-desktop.ts +37 -13
  37. package/src/cli/cross-home-owner.ts +138 -0
  38. package/src/cli/dispatch.ts +1 -1
  39. package/src/cli/doctor.ts +18 -8
  40. package/src/cli/ensure-desired-integrations.ts +5 -0
  41. package/src/cli/export-command.ts +6 -6
  42. package/src/cli/help.ts +1 -1
  43. package/src/cli/index.ts +15 -18
  44. package/src/cli/integrations.ts +8 -1
  45. package/src/cli/models-runtime-subcommands.ts +1 -0
  46. package/src/cli/models-runtime.ts +106 -4
  47. package/src/cli/models.ts +6 -1
  48. package/src/cli/opencode.ts +2 -83
  49. package/src/cli/registry.ts +3 -2
  50. package/src/cli/status.ts +86 -7
  51. package/src/client/connect.ts +24 -10
  52. package/src/client/link-ingress.ts +3 -1
  53. package/src/client/link-join.ts +76 -9
  54. package/src/client/link-relay-transport.ts +184 -0
  55. package/src/client/link-relay.ts +16 -4
  56. package/src/client/machine-listener.ts +2 -0
  57. package/src/clients/config-export/constants.ts +8 -0
  58. package/src/clients/config-export/contracts.ts +13 -1
  59. package/src/clients/config-export/droid.ts +71 -0
  60. package/src/clients/config-export/kilo.ts +116 -0
  61. package/src/clients/config-export.ts +33 -2
  62. package/src/codex/auth-api/routes.ts +1 -1
  63. package/src/codex/catalog/access-programs.ts +14 -2
  64. package/src/codex/catalog/provider-models.ts +1 -1
  65. package/src/codex/catalog-auto-refresh.ts +122 -0
  66. package/src/codex/config-drift-heal.ts +80 -0
  67. package/src/codex/desktop-switches.ts +7 -5
  68. package/src/codex/inject/bounded-config-reader.ts +69 -0
  69. package/src/codex/inject/config-toml.ts +31 -3
  70. package/src/codex/inject/routing-target.ts +6 -1
  71. package/src/codex/inject.ts +4 -0
  72. package/src/codex/model-entitlements.ts +32 -5
  73. package/src/codex/native-profile-startup.ts +17 -5
  74. package/src/codex/project-config-warnings.ts +72 -21
  75. package/src/codex/quota-auto-refresh-state.ts +8 -2
  76. package/src/codex/quota-auto-refresh.ts +80 -9
  77. package/src/codex/sync.ts +10 -0
  78. package/src/combos/failover.ts +4 -1
  79. package/src/combos/jev.ts +15 -4
  80. package/src/combos/types.ts +16 -0
  81. package/src/config/diagnostics.ts +19 -1
  82. package/src/config/load-degrade.ts +29 -0
  83. package/src/config/macos-system-proxy.ts +114 -0
  84. package/src/config/proxy-env.ts +67 -20
  85. package/src/config/schema/config-schema.ts +15 -0
  86. package/src/config/schema/leaf-validators.ts +23 -0
  87. package/src/config.ts +2 -0
  88. package/src/generated/compatibility-version.json +220 -144
  89. package/src/integrations/catalog-refresh.ts +1 -1
  90. package/src/integrations/config-io.ts +12 -3
  91. package/src/integrations/droid-settings.ts +65 -0
  92. package/src/integrations/kilo-candidates.ts +30 -0
  93. package/src/integrations/mutation-plan.ts +62 -7
  94. package/src/integrations/native/ownership-preflight.ts +46 -12
  95. package/src/integrations/registry.ts +136 -7
  96. package/src/integrations/state.ts +52 -4
  97. package/src/integrations/target.ts +3 -0
  98. package/src/integrations/writer.ts +55 -14
  99. package/src/lib/account-selection-events.ts +14 -0
  100. package/src/lib/jsonc.ts +110 -0
  101. package/src/lib/standalone.ts +9 -1
  102. package/src/lib/windows-system-proxy.ts +101 -1
  103. package/src/link/relay-auth.ts +29 -0
  104. package/src/link/ssh-argv.ts +6 -3
  105. package/src/link/ssh-runner.ts +5 -5
  106. package/src/oauth/generic-account-failover.ts +52 -8
  107. package/src/oauth/index.ts +37 -14
  108. package/src/oauth/store.ts +96 -11
  109. package/src/oauth/token-guardian.ts +7 -3
  110. package/src/oauth/types.ts +2 -0
  111. package/src/plugins/loader.ts +5 -3
  112. package/src/providers/fastwire.ts +3 -0
  113. package/src/providers/key-failover.ts +13 -5
  114. package/src/providers/kiro-model-catalog.ts +4 -3
  115. package/src/providers/model-rename-fields.ts +1 -0
  116. package/src/providers/openai-tiers-destination.ts +9 -0
  117. package/src/providers/quota/account-cache.ts +17 -3
  118. package/src/providers/quota/vendor-probes-oauth.ts +2 -0
  119. package/src/providers/quota.ts +2 -2
  120. package/src/responses/code-mode-helper-compat.ts +14 -1
  121. package/src/responses/custom-tool-compat.ts +1 -1
  122. package/src/server/auth-cors.ts +1 -0
  123. package/src/server/images.ts +70 -20
  124. package/src/server/index/link-listener.ts +20 -3
  125. package/src/server/index/link-relay-sessions.ts +138 -0
  126. package/src/server/index/optional-listeners.ts +11 -1
  127. package/src/server/index/serve-options.ts +12 -0
  128. package/src/server/inference/client-encoder-delivery.ts +1 -0
  129. package/src/server/local-management-read-client.ts +21 -2
  130. package/src/server/management/agent-settings-routes.ts +19 -5
  131. package/src/server/management/combo-routes.ts +5 -3
  132. package/src/server/management/config-routes.ts +32 -6
  133. package/src/server/management/model-routes.ts +232 -7
  134. package/src/server/management/model-rows.ts +199 -1
  135. package/src/server/management/oauth-account-routes.ts +63 -3
  136. package/src/server/management/route-registry.ts +2 -0
  137. package/src/server/port-reclaim.ts +170 -32
  138. package/src/server/responses/adapter-delivery.ts +6 -4
  139. package/src/server/responses/adapter-dispatch.ts +72 -2
  140. package/src/server/responses/core-combo.ts +6 -1
  141. package/src/server/responses/core-normalize.ts +19 -4
  142. package/src/server/responses/core-options.ts +2 -0
  143. package/src/server/responses/memory-models.ts +169 -0
  144. package/src/server/responses/passthrough-delivery.ts +4 -0
  145. package/src/server/responses/passthrough-dispatch.ts +10 -0
  146. package/src/server/responses/request-prepare.ts +86 -4
  147. package/src/server/responses/request-transport.ts +4 -0
  148. package/src/server/responses/run-turn-execution.ts +5 -2
  149. package/src/server/responses/shadow-target-availability.ts +17 -4
  150. package/src/server/responses/sidecar-execution.ts +18 -2
  151. package/src/server/responses/skills-snapshot.ts +239 -0
  152. package/src/server/responses-undeclared-tool-guard.ts +43 -6
  153. package/src/server/search.ts +1 -1
  154. package/src/service/guards.ts +4 -5
  155. package/src/service/state.ts +39 -2
  156. package/src/service.ts +2 -2
  157. package/src/types/config.ts +46 -4
  158. package/src/types/provider.ts +9 -0
  159. package/src/types/request.ts +6 -0
  160. package/src/types/tools.ts +38 -8
  161. package/src/types.ts +3 -0
  162. package/src/update/async-check.ts +5 -1
  163. package/src/update/index.ts +30 -12
  164. package/src/update/job.ts +3 -1
  165. package/src/update/pnpm-read-policy.d.mts +4 -0
  166. package/src/update/pnpm-read-policy.mjs +44 -0
  167. package/src/update/worker-launch.ts +152 -11
  168. package/src/usage/cost.ts +6 -3
  169. package/src/usage/log.ts +4 -0
  170. package/src/web-search/backends.ts +2 -2
  171. package/src/web-search/devin-executor.ts +1 -1
  172. package/src/web-search/sidecar-providers.ts +5 -5
  173. package/gui/dist/assets/App-mpsibsFj.js +0 -51
  174. package/gui/dist/assets/index-BDUBS8PW.css +0 -1
  175. package/gui/dist/assets/index-kUE8tuAK.js +0 -86
@@ -119,7 +119,7 @@ export async function deliverAdapterResponse(
119
119
  continuation: fetchGuardedEmptyCompletionRetry,
120
120
  })
121
121
  : eventStream;
122
- const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
122
+ const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames } = toolBridgeMaps;
123
123
  // One completion owner for both deliveries: the bridge calls it from its terminal, the
124
124
  // direct client encoder from the fold of the same events.
125
125
  const onCompletedResponse = (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
@@ -153,7 +153,7 @@ export async function deliverAdapterResponse(
153
153
  fold: {
154
154
  replayCacheScope: parsed._reasoningReplayScope,
155
155
  hideThinkingSummary: parsed.options.hideThinkingSummary,
156
- toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames,
156
+ toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames,
157
157
  },
158
158
  stallTimeoutSec: config.stallTimeoutSec,
159
159
  localUpstream,
@@ -176,8 +176,9 @@ export async function deliverAdapterResponse(
176
176
  localUpstream,
177
177
  hideThinkingSummary: parsed.options.hideThinkingSummary,
178
178
  declaredToolNames,
179
+ bareCustomToolNames,
179
180
  enforceDeclaredToolNames: options.inboundWire !== "chat" && options.inboundWire !== "anthropic",
180
- toolParameterSchemas,
181
+ toolParameterSchemas,
181
182
  ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
182
183
  ...(routedCompaction ? { compaction: true } : {}),
183
184
  // Same grok-surface split as the runTurn branch above.
@@ -236,7 +237,7 @@ export async function deliverAdapterResponse(
236
237
  } finally {
237
238
  cleanupUpstreamAbort();
238
239
  }
239
- const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
240
+ const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames } = toolBridgeMaps;
240
241
  let providerState: OcxProviderContinuationState | undefined;
241
242
  const json = buildResponseJSON(events, parsed._responseModelId ?? parsed.modelId, {
242
243
  translatorBudget,
@@ -244,6 +245,7 @@ export async function deliverAdapterResponse(
244
245
  hideThinkingSummary: parsed.options.hideThinkingSummary,
245
246
  toolNsMap,
246
247
  declaredToolNames,
248
+ bareCustomToolNames,
247
249
  enforceDeclaredToolNames: options.inboundWire !== "chat" && options.inboundWire !== "anthropic",
248
250
  toolParameterSchemas,
249
251
  freeformToolNames,
@@ -42,7 +42,8 @@ import { describeUpstreamConnectFailure } from "./upstream-error";
42
42
  import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
43
43
  import type { AttemptRecoveryKind } from "../../usage/log";
44
44
  import type { OAuthAccessSnapshot } from "../../oauth";
45
- import { OAuthLoginRequiredError, publicOAuthAuthenticationErrorMessage } from "../../oauth";
45
+ import { OAuthAccountPausedError, OAuthLoginRequiredError, publicOAuthAuthenticationErrorMessage } from "../../oauth";
46
+ import { getAccountSet } from "../../oauth/store";
46
47
  import { tryKiroAlternateAfterTerminalRefresh } from "../../oauth/kiro-terminal-failover";
47
48
  import { classifyKiroRefusal } from "../../adapters/kiro-refusal";
48
49
  import { normalizeFinalKiroHttpError } from "../../adapters/kiro-retry";
@@ -64,6 +65,7 @@ import {
64
65
  rotateGenericOAuthAccountOnRefusal,
65
66
  quarantineKiroSuspendedAccount,
66
67
  failoverAccountSnapshot,
68
+ rotateAntigravityAccountOnAuthRefusal,
67
69
  } from "../../oauth/generic-account-failover";
68
70
  import {
69
71
  attemptOpaqueBlobRecovery,
@@ -197,6 +199,8 @@ export async function prepareAdapterExchange(
197
199
  // Key/OAuth rotation re-resolves credentials, never the origin, so the routed baseUrl is stable.
198
200
  const localUpstream = isLocalUpstream(route.provider.baseUrl);
199
201
  transportState.activeAdapter = transportState.adapter;
202
+ const antigravityPoolActivated = route.providerName === "google-antigravity"
203
+ && isGenericOAuthFailoverEnabled(config, route.providerName);
200
204
 
201
205
  // One immutable, body-safe outbound request per same-target sequence (URL, serialized body,
202
206
  // auth headers, generated compat headers). Same-target 429 replays reuse it verbatim; the
@@ -409,6 +413,7 @@ export async function prepareAdapterExchange(
409
413
  // moments later; at most one byte-identical replay is allowed per request.
410
414
  const consoleGoUploadRetryGuard: { attempted: boolean } = { attempted: false };
411
415
  let oauth401ReplayAttempted = false;
416
+ let antigravity401RotationAttempted = false;
412
417
  // At most one reasoning-effort downgrade per request. This sits outside the recovery loop
413
418
  // below for the same reason the two guards above do: a guard declared inside it is reset by
414
419
  // every `continue recovery`, which would let one turn walk the whole ladder down.
@@ -588,6 +593,54 @@ export async function prepareAdapterExchange(
588
593
  return { failed: formatErrorResponse(502, "upstream_error", msg) };
589
594
  }
590
595
  };
596
+ const rotateAntigravityAuth = async (
597
+ failedResponse: Response,
598
+ recovery: AttemptRecoveryKind,
599
+ ): Promise<Response | null> => {
600
+ const sent = transportState.replayOAuthCredentialSnapshot ?? transportState.sentOAuthSnapshot;
601
+ if (!antigravityPoolActivated || !sent
602
+ || transportState.genericFailovers >= transportState.genericFailoverLimit) return null;
603
+ const nextId = rotateAntigravityAccountOnAuthRefusal(
604
+ antigravityPoolActivated, sent.accountId, sent.generation, route.modelId,
605
+ );
606
+ if (!nextId) return null;
607
+ const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
608
+ const hop = reserveCredentialHop("auth-recovery",
609
+ `${route.providerName}|${route.modelId}|antigravity-auth`,
610
+ !adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null);
611
+ if (!hop.allowed) return null;
612
+ try {
613
+ const snapshot = await failoverAccountSnapshot(route.providerName, nextId);
614
+ const admitted = await applyFailoverSnapshot(snapshot);
615
+ if (admitted?.accountId !== nextId) return null;
616
+ invalidateSameTargetRequest();
617
+ transportState.activeAdapter = resolveSelectionAdapter(
618
+ resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
619
+ config.cacheRetention);
620
+ bindRouteReasoningReplayScope({
621
+ parsed, providerName: route.providerName, provider: route.provider,
622
+ adapterName: transportState.activeAdapter.name,
623
+ oauthCredentialSnapshot: transportState.replayOAuthCredentialSnapshot,
624
+ });
625
+ sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider,
626
+ transportState.activeAdapter.name, logCtx.accountLogLabel);
627
+ recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName,
628
+ route.provider, transportState.activeAdapter.name);
629
+ sendBudgetState.pendingHopPermit = hop.permit;
630
+ const result = await rebuildAndRefetch(recovery, () => {
631
+ if (!adapterOwnsDispatch) hop.permit?.use();
632
+ }, failedResponse);
633
+ if ("failed" in result) return result.failed === failedResponse ? null : result.failed;
634
+ transportState.genericFailovers += 1;
635
+ try { void failedResponse.body?.cancel().catch(() => {}); } catch { /* already closed */ }
636
+ return result;
637
+ } catch {
638
+ return null;
639
+ } finally {
640
+ sendBudgetState.pendingHopPermit = undefined;
641
+ hop.permit?.release();
642
+ }
643
+ };
591
644
  // Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above.
592
645
  recovery: for (;;) {
593
646
  // Preserve the terminal verdict through adapter and combo error formatting.
@@ -604,12 +657,18 @@ export async function prepareAdapterExchange(
604
657
  && !sendBudgetExhausted()
605
658
  ) {
606
659
  oauth401ReplayAttempted = true;
607
- try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
608
660
  let refreshed: OAuthAccessSnapshot;
609
661
  try {
610
662
  refreshed = await refreshResolvedOAuthSelection(transportState.sentOAuthSnapshot);
611
663
  } catch (err) {
612
664
  const failed = transportState.sentOAuthSnapshot;
665
+ if (route.providerName === "google-antigravity" && err instanceof OAuthLoginRequiredError && failed
666
+ && getAccountSet(route.providerName)?.accounts.some(row =>
667
+ row.id === failed.accountId && row.needsReauth === true)) {
668
+ antigravity401RotationAttempted = true;
669
+ const rotated = await rotateAntigravityAuth(upstreamResponse, "oauth-401");
670
+ if (rotated) { upstreamResponse = rotated; continue recovery; }
671
+ }
613
672
  if (route.providerName === "kiro" && err instanceof OAuthLoginRequiredError && failed
614
673
  && transportState.genericFailovers < transportState.genericFailoverLimit) {
615
674
  const alternate = await tryKiroAlternateAfterTerminalRefresh(config, failed.accountId, failed.generation);
@@ -650,6 +709,9 @@ export async function prepareAdapterExchange(
650
709
  }
651
710
  }
652
711
  cleanupUpstreamAbort();
712
+ if (err instanceof OAuthAccountPausedError) {
713
+ return formatErrorResponse(403, "permission_error", publicOAuthAuthenticationErrorMessage(err));
714
+ }
653
715
  return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err));
654
716
  }
655
717
  if (route.provider.googleMode === "cloud-code-assist" && !refreshed.projectId) {
@@ -695,6 +757,14 @@ export async function prepareAdapterExchange(
695
757
  continue recovery;
696
758
  }
697
759
 
760
+ if (route.providerName === "google-antigravity" && upstreamResponse.status === 401
761
+ && oauth401ReplayAttempted && !antigravity401RotationAttempted
762
+ && !isNonReplayableResponse(upstreamResponse)) {
763
+ antigravity401RotationAttempted = true;
764
+ const rotated = await rotateAntigravityAuth(upstreamResponse, "oauth-401");
765
+ if (rotated) { upstreamResponse = rotated; continue recovery; }
766
+ }
767
+
698
768
  // Static API-key pools can recover a credential-scoped 401 without abandoning the
699
769
  // provider: one revoked or mistyped key says nothing about its siblings. OAuth providers
700
770
  // refresh above and never enter here — `hasKeyPoolFailover` rejects oauth/forward modes.
@@ -45,6 +45,7 @@ import {
45
45
  } from "../../responses/state";
46
46
  import { hasUnreadableEncryptedAgentTask } from "./encrypted-payload";
47
47
  import { routeConcreteModel, comboRouteDecisionTrace } from "../../router";
48
+ import { memoryModelRouteReason } from "./memory-models";
48
49
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
49
50
  import type { AgentTaskRecoveryFailureReason } from "./agent-task-recovery";
50
51
  import {
@@ -226,6 +227,7 @@ function eligibleJevComboChoices(
226
227
  provider: pick.target.provider,
227
228
  model: pick.target.model,
228
229
  reasoningEfforts,
230
+ modelProfile: pick.target.modelProfile,
229
231
  },
230
232
  });
231
233
  }
@@ -558,7 +560,10 @@ export async function executeComboResponses(
558
560
  }
559
561
  // One immutable combo selection trace, before any child dispatch; child
560
562
  // adoption below must never replace it with a concrete child route trace.
561
- logCtx.routeDecision = comboRouteDecisionTrace(config, comboId, pick, requestedModel);
563
+ const decision = comboRouteDecisionTrace(config, comboId, pick, requestedModel);
564
+ logCtx.routeDecision = options.memoryModelPhase
565
+ ? { ...decision, selected: { ...decision.selected, reason: memoryModelRouteReason(options.memoryModelPhase) } }
566
+ : decision;
562
567
 
563
568
  const originalReasoning = body && typeof body === "object" && !Array.isArray(body)
564
569
  ? (body as { reasoning?: unknown }).reasoning
@@ -17,6 +17,7 @@ import { resolveOpenCodeGoTransport } from "../../providers/opencode-go-transpor
17
17
  import { getOrAllocateRequestSessionLane } from "../request-log-conversation";
18
18
  import { shouldPreparePlaintextV2AgentMessages } from "../../responses/plaintext-v2-agent-messages";
19
19
  import { hasValidatedActiveReasoningEffort } from "../../responses/parser";
20
+ import { responseTierAuthorityForProvider } from "../../providers/openai-tiers-destination";
20
21
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
21
22
  import { applyOpenAiVirtualModel } from "../../providers/openai-virtual-models";
22
23
  import { renameRoutedIdentityInContext } from "../../adapters/identity";
@@ -220,14 +221,12 @@ export async function applyFinalRouteRequestNormalization(args: {
220
221
  );
221
222
  const modelServiceTierSupport = serviceTierSupportFromPolicy(fastPolicy);
222
223
  const callerTier = parsed.options.serviceTier;
223
- // The ChatGPT-internal Codex backend echoes `service_tier: "default"` even on turns it
224
- // scheduled as priority, so its echo cannot confirm OR deny Fast. Believing it reported every
225
- // Fast request as `response-declined` (#2558). The public API's echo stays authoritative.
224
+ // Capture destination evidence policy separately from the unchanged outbound Fast decision.
226
225
  parsed.options.tierObservation = tierObservationContext(
227
226
  fastPolicy,
228
227
  config.fastMode,
229
228
  callerTier,
230
- isCanonicalOpenAiForwardProvider(route.provider) ? false : undefined,
229
+ responseTierAuthorityForProvider(route.provider),
231
230
  );
232
231
  parsed.options.tierDecision = decideTier(fastPolicy, config.fastMode, callerTier);
233
232
  parsed.options.serviceTier = tierValueAfterDecision(parsed.options.tierDecision, callerTier);
@@ -279,6 +278,22 @@ export async function applyFinalRouteRequestNormalization(args: {
279
278
  }
280
279
  }
281
280
 
281
+ {
282
+ // Last word on a memory turn's effort: the phase setting is more specific than a provider-wide
283
+ // pin, and Codex hard-codes the phase effort with no config key of its own.
284
+ const phase = parsed._memoryModelPhase;
285
+ if (phase) {
286
+ const { applyMemoryModelEffort } = await import("./memory-models");
287
+ const applied = applyMemoryModelEffort(parsed, config, phase);
288
+ if (applied) {
289
+ logCtx.requestedEffort = applied.from ? `${applied.from}->${applied.to}` : applied.to;
290
+ if (isInjectionDebugEnabled()) {
291
+ injectionDebugLog(`[opencodex] ${route.modelId}: memory ${phase} effort applied (${applied.from ?? "none"} -> ${applied.to})`);
292
+ }
293
+ }
294
+ }
295
+ }
296
+
282
297
  {
283
298
  const { applyEffortCap, effortCapAppliesTo, supportedLadderFor } = await import("../effort-policy");
284
299
  const surface = collabSurface(parsed);
@@ -145,6 +145,8 @@ export interface HandleResponsesOptions {
145
145
  comboAttempt?: boolean;
146
146
  /** Internal handoff: this combo was selected by shadow-call interception. */
147
147
  shadowCallIntercepted?: boolean;
148
+ /** Internal handoff: the memory phase this turn belongs to, so combo children keep its routing. */
149
+ memoryModelPhase?: "extract" | "consolidation";
148
150
  compactionRoutingOverride?: CompactionRoutingOverride | null;
149
151
  /** Internal combo handoff for one parent-validated continuation snapshot. */
150
152
  comboReplaySnapshot?: {
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Model routing for Codex's own memory pipeline.
3
+ *
4
+ * Codex writes memories in two background phases, and both ask the provider for a bare native
5
+ * model: Phase 1 ("extract") summarizes one finished thread per call and asks for
6
+ * `gpt-5.6-luna` at effort `low`; Phase 2 ("consolidation") is one agent run that merges those
7
+ * summaries into the files under `$CODEX_HOME/memories` and asks for `gpt-5.6-terra` at effort
8
+ * `medium`. Without a configured target each keeps its existing route; Phase 1 additionally looks like the app's
9
+ * title/commit helper traffic, because the app uses the same model id for those.
10
+ *
11
+ * A phase is therefore recognized from Codex's own turn metadata, never inferred from the model
12
+ * id, the timing, or the token counts. Phase 1 sends `request_kind: "memory"`; both phases carry
13
+ * `thread_source: "memory_consolidation"`, and Phase 2 additionally arrives with
14
+ * `x-openai-subagent: memory_consolidation` (codex-rs `core/src/responses_metadata.rs`).
15
+ */
16
+ import type { OcxConfig, OcxParsedRequest } from "../../types";
17
+ import { isDeclaredReasoningEffort } from "../../reasoning-effort";
18
+
19
+ /** The two phases Codex runs, in the order it runs them. */
20
+ export type MemoryModelPhase = "extract" | "consolidation";
21
+
22
+ /** codex-rs serializes both keys below into the JSON `x-codex-turn-metadata` header. */
23
+ const TURN_METADATA_HEADER = "x-codex-turn-metadata";
24
+ const REQUEST_KIND_KEY = "request_kind";
25
+ const THREAD_SOURCE_KEY = "thread_source";
26
+ /** `CodexResponsesRequestKind::Memory` (codex-rs `core/src/responses_metadata.rs`). */
27
+ const MEMORY_REQUEST_KIND = "memory";
28
+ /** `ThreadSource::MemoryConsolidation` / `InternalSessionSource::MemoryConsolidation`. */
29
+ const MEMORY_THREAD_SOURCE = "memory_consolidation";
30
+ const SUBAGENT_HEADER = "x-openai-subagent";
31
+
32
+ function record(value: unknown): Record<string, unknown> | undefined {
33
+ return value !== null && typeof value === "object" && !Array.isArray(value)
34
+ ? value as Record<string, unknown>
35
+ : undefined;
36
+ }
37
+
38
+ /** One metadata copy's verdict. `"none"` is a well-formed copy that is not a memory turn. */
39
+ type CopyVerdict = MemoryModelPhase | "none";
40
+
41
+ function verdictOf(parsed: Record<string, unknown>): CopyVerdict {
42
+ // Phase 1's detached request names the memory kind explicitly. Phase 2 is an ordinary turn
43
+ // inside the `memory_consolidation` thread, so its thread source is the only signal there.
44
+ if (parsed[REQUEST_KIND_KEY] === MEMORY_REQUEST_KIND) return "extract";
45
+ if (parsed[THREAD_SOURCE_KEY] === MEMORY_THREAD_SOURCE) return "consolidation";
46
+ return "none";
47
+ }
48
+
49
+ /**
50
+ * Recognize a memory-pipeline turn, or null.
51
+ *
52
+ * Every copy of the turn metadata the request carries must agree — the same rule
53
+ * `applyCompactionRoutingOverride` applies to compaction turns: a request that contradicts itself
54
+ * is not a memory turn, so neither copy can widen what the setting covers. On HTTP the sub-agent
55
+ * header is accepted on its own because Codex may deliver only that copy; on websocket it is not,
56
+ * because the bridge re-attaches the handshake's header to every frame, so there it marks the
57
+ * connection rather than the turn and the per-frame metadata decides alone.
58
+ */
59
+ export function detectMemoryModelPhase(
60
+ body: unknown,
61
+ headers: Headers,
62
+ options: { transport?: "websocket" } = {},
63
+ ): MemoryModelPhase | null {
64
+ const metadata: unknown[] = [];
65
+ const header = headers.get(TURN_METADATA_HEADER);
66
+ if (options.transport !== "websocket" && header !== null) metadata.push(header);
67
+ const bodyRecord = record(body);
68
+ const rawClient = bodyRecord?.["client_metadata"];
69
+ // Present but malformed client metadata is a turn that failed validation, not an absent
70
+ // copy; it must not fall through to the connection-level sub-agent header below.
71
+ if (bodyRecord && Object.hasOwn(bodyRecord, "client_metadata") && !record(rawClient)) return null;
72
+ const client = record(rawClient);
73
+ if (client && Object.hasOwn(client, TURN_METADATA_HEADER)) metadata.push(client[TURN_METADATA_HEADER]);
74
+
75
+ let verdict: CopyVerdict | null = null;
76
+ for (const value of metadata) {
77
+ if (typeof value !== "string") return null;
78
+ let parsed: Record<string, unknown> | undefined;
79
+ try {
80
+ parsed = record(JSON.parse(value));
81
+ } catch {
82
+ return null;
83
+ }
84
+ if (!parsed) return null;
85
+ const copy = verdictOf(parsed);
86
+ if (verdict !== null && verdict !== copy) return null;
87
+ verdict = copy;
88
+ }
89
+ if (verdict === "extract" || verdict === "consolidation") return verdict;
90
+ // A validated non-memory turn is an explicit decision. The connection-level sub-agent
91
+ // header is only a compatibility signal when turn metadata is wholly absent.
92
+ if (verdict === "none") return null;
93
+ // The websocket bridge rebuilds internal requests from a header allowlist and re-attaches the
94
+ // handshake's sub-agent header to every frame. Trusting it here would sweep the connection's
95
+ // later ordinary turns into the consolidation phase, so websocket frames rely on the per-frame
96
+ // turn metadata above and nothing else.
97
+ if (options.transport === "websocket") return null;
98
+ return headers.get(SUBAGENT_HEADER) === MEMORY_THREAD_SOURCE ? "consolidation" : null;
99
+ }
100
+
101
+ /** The configured destination for one phase, or undefined while the phase keeps Codex's choice. */
102
+ export function configuredMemoryModel(
103
+ config: Pick<OcxConfig, "memoryModels"> | undefined,
104
+ phase: MemoryModelPhase,
105
+ ): { model: string; reasoningEffort?: string } | undefined {
106
+ const setting = config?.memoryModels?.[phase];
107
+ if (!setting) return undefined;
108
+ const model = typeof setting.model === "string" ? setting.model.trim() : "";
109
+ if (!model) return undefined;
110
+ const effort = typeof setting.reasoningEffort === "string" ? setting.reasoningEffort : undefined;
111
+ return { model, ...(effort ? { reasoningEffort: effort } : {}) };
112
+ }
113
+
114
+ /**
115
+ * Force the configured effort onto a memory turn.
116
+ *
117
+ * Codex hard-codes the phase effort (`low` for Phase 1, `medium` for Phase 2) and has no config
118
+ * key for it, so this is the only place the operator's choice can land. Both wire shapes are
119
+ * written: `parsed.options.reasoning` feeds the routed adapters, `_rawBody.reasoning.effort` feeds
120
+ * the ChatGPT passthrough serializer — the same dual-shape contract `applyPinnedEffort` uses.
121
+ */
122
+ export function applyMemoryModelEffort(
123
+ parsed: OcxParsedRequest,
124
+ config: Pick<OcxConfig, "memoryModels"> | undefined,
125
+ phase: MemoryModelPhase,
126
+ ): { from: string | undefined; to: string } | null {
127
+ const effort = configuredMemoryModel(config, phase)?.reasoningEffort;
128
+ if (!effort || !isDeclaredReasoningEffort(effort)) return null;
129
+ const requested = parsed.options.reasoning;
130
+ if (requested === effort) return null;
131
+ parsed.options.reasoning = effort;
132
+ const raw = parsed._rawBody as { reasoning?: { effort?: string } } | undefined;
133
+ if (raw && typeof raw === "object") {
134
+ raw.reasoning = { ...(record(raw.reasoning) ?? {}), effort } as { effort?: string };
135
+ }
136
+ return { from: requested, to: effort };
137
+ }
138
+
139
+ /** Route reason recorded for a routed memory turn, so the request log names the phase. */
140
+ export function memoryModelRouteReason(phase: MemoryModelPhase): string {
141
+ return phase === "extract" ? "memory-extract" : "memory-consolidation";
142
+ }
143
+
144
+ /** Non-retryable: the target stays unavailable until the operator changes the setting. */
145
+ export const MEMORY_MODEL_TARGET_UNAVAILABLE_CODE = "memory_model_target_unavailable";
146
+ export const MEMORY_MODEL_TARGET_UNAVAILABLE_STATUS = 409;
147
+
148
+ const warnedPhases = new Set<MemoryModelPhase>();
149
+
150
+ /**
151
+ * A configured phase destination that stopped resolving fails its call once, clearly, instead of
152
+ * silently falling back to the native model the operator routed away from — the same contract the
153
+ * shadow intercept uses for its single target.
154
+ */
155
+ export function memoryModelTargetUnavailableResponse(
156
+ phase: MemoryModelPhase,
157
+ ): Response {
158
+ // A hand-edited model id or resolver detail may contain a credential-bearing URL. Neither
159
+ // belongs in logs or the client error; the settings panel already displays the saved target.
160
+ const message = `Memory ${phase} model is unavailable. Choose another model in Memory routing or re-enable its provider.`;
161
+ if (!warnedPhases.has(phase)) {
162
+ warnedPhases.add(phase);
163
+ console.warn(`memory-models: ${message}`);
164
+ }
165
+ return new Response(
166
+ JSON.stringify({ error: { message, type: "invalid_request_error", code: MEMORY_MODEL_TARGET_UNAVAILABLE_CODE } }),
167
+ { status: MEMORY_MODEL_TARGET_UNAVAILABLE_STATUS, headers: { "Content-Type": "application/json" } },
168
+ );
169
+ }
@@ -313,6 +313,7 @@ export async function deliverPassthroughResponse(
313
313
  | "declaredNamelessClientCallTypes"
314
314
  | "providerExecutedCallTypes"
315
315
  | "declaredBareWireToolNames"
316
+ | "recoverableBareCustomWireToolNames"
316
317
  | "rememberPassthroughResponse"
317
318
  | "noteInspectedPayload"
318
319
  | "normalizeFunctionCompletionJson"
@@ -336,6 +337,7 @@ export async function deliverPassthroughResponse(
336
337
  declaredNamelessClientCallTypes,
337
338
  providerExecutedCallTypes,
338
339
  declaredBareWireToolNames,
340
+ recoverableBareCustomWireToolNames,
339
341
  rememberPassthroughResponse,
340
342
  noteInspectedPayload,
341
343
  normalizeFunctionCompletionJson,
@@ -732,6 +734,7 @@ export async function deliverPassthroughResponse(
732
734
  declaredNamelessClientCallTypes,
733
735
  providerExecutedCallTypes,
734
736
  declaredBareWireToolNames,
737
+ recoverableBareCustomWireToolNames,
735
738
  )
736
739
  : undefined,
737
740
  grokUpstreamEchoEnabled
@@ -998,6 +1001,7 @@ export async function deliverPassthroughResponse(
998
1001
  declaredNamelessClientCallTypes,
999
1002
  providerExecutedCallTypes,
1000
1003
  declaredBareWireToolNames,
1004
+ recoverableBareCustomWireToolNames,
1001
1005
  );
1002
1006
  } catch {
1003
1007
  return undefined;
@@ -22,6 +22,7 @@ import {
22
22
  hasExplicitWireToolCatalog,
23
23
  collectDeclaredWireToolNames,
24
24
  collectDeclaredBareWireToolNames,
25
+ collectDeclaredBareCustomWireToolNames,
25
26
  collectDeclaredNamelessClientCallTypes,
26
27
  collectProviderExecutedCallTypes,
27
28
  undeclaredToolCallName,
@@ -314,6 +315,7 @@ export async function preparePassthroughExchange(
314
315
  const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(clientToolAuthorizationBody);
315
316
  const clientDeclaredWireToolNames = collectDeclaredWireToolNames(clientToolAuthorizationBody);
316
317
  const clientDeclaredBareWireToolNames = collectDeclaredBareWireToolNames(clientToolAuthorizationBody);
318
+ const clientDeclaredBareCustomWireToolNames = collectDeclaredBareCustomWireToolNames(clientToolAuthorizationBody);
317
319
  const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes(
318
320
  clientToolAuthorizationBody,
319
321
  );
@@ -361,6 +363,11 @@ export async function preparePassthroughExchange(
361
363
  ) routedCustomToolRepairNames.add(name);
362
364
  }
363
365
  }
366
+ // Only grant direct MCP recovery when this delivery actually restores converted custom
367
+ // calls. Native forward and injection paths have no such rewrite.
368
+ const recoverableBareCustomWireToolNames = new Set(
369
+ [...clientDeclaredBareCustomWireToolNames].filter(name => routedCustomToolNames.has(name)),
370
+ );
364
371
  for (const name of request.convertedRoutedToolSearchNames ?? []) {
365
372
  // The adapter already keeps this set empty when tool_choice forbids the private search.
366
373
  // Its wire name may be collision-aliased, so comparing it to the caller-facing name here
@@ -560,6 +567,7 @@ export async function preparePassthroughExchange(
560
567
  declaredNamelessClientCallTypes,
561
568
  providerExecutedCallTypes,
562
569
  declaredBareWireToolNames,
570
+ recoverableBareCustomWireToolNames,
563
571
  ) !== undefined) {
564
572
  inspectionSawUndeclaredTool = true;
565
573
  }
@@ -605,6 +613,7 @@ export async function preparePassthroughExchange(
605
613
  declaredNamelessClientCallTypes,
606
614
  providerExecutedCallTypes,
607
615
  declaredBareWireToolNames,
616
+ recoverableBareCustomWireToolNames,
608
617
  ) !== undefined
609
618
  ) {
610
619
  return;
@@ -1815,6 +1824,7 @@ export async function preparePassthroughExchange(
1815
1824
  },
1816
1825
  declaredWireToolNames,
1817
1826
  declaredBareWireToolNames,
1827
+ recoverableBareCustomWireToolNames,
1818
1828
  declaredNamelessClientCallTypes,
1819
1829
  authorizedBareNamespaceToolAliases,
1820
1830
  normalizeFunctionCompletionJson,