@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
@@ -13,7 +13,14 @@ import {
13
13
  } from "./core-errors";
14
14
  import { parseSyntheticRowId } from "../fast-row";
15
15
  import { resolveComboId, comboIdFromRawBody, NoAvailableComboTargetsError } from "../../combos";
16
- import { INTERCEPT_TARGET_UNAVAILABLE_CODE, interceptTargetUnavailableResponse, resolveShadowCallTarget } from "./shadow-target-availability";
16
+ import { INTERCEPT_TARGET_UNAVAILABLE_CODE, interceptTargetUnavailableResponse, resolveChosenTarget, resolveShadowCallTarget } from "./shadow-target-availability";
17
+ import {
18
+ MEMORY_MODEL_TARGET_UNAVAILABLE_CODE,
19
+ configuredMemoryModel,
20
+ detectMemoryModelPhase,
21
+ memoryModelRouteReason,
22
+ memoryModelTargetUnavailableResponse,
23
+ } from "./memory-models";
17
24
  import { recallComboForLane } from "./combo-session-recall";
18
25
  import {
19
26
  sessionLaneIdFromRequest,
@@ -22,6 +29,7 @@ import {
22
29
  reasoningReplayConversationIdFromResponsesRequest,
23
30
  } from "../request-log-conversation";
24
31
  import { resolveContextPrincipal } from "../auth-cors";
32
+ import { resolveSkillsSnapshotScopeKey, snapshotSkillsCatalogInBody } from "./skills-snapshot";
25
33
  import {
26
34
  isShadowSourceModel,
27
35
  shadowSourceModelPrefix,
@@ -174,6 +182,20 @@ export async function prepareResponsesRequest(
174
182
  transport: options.inboundTransport,
175
183
  });
176
184
  }
185
+ // Codex's memory pipeline names a destination per phase. The phase is read from Codex's own turn
186
+ // metadata, never from the model id: Phase 1 shares `gpt-5.6-luna` with the app's title/commit
187
+ // helper calls. Read here, ahead of the shadow intercept below, because the phase decision is the
188
+ // more specific of the two settings and must be the one that survives when both match one request.
189
+ const memoryModelPhase = options.memoryModelPhase
190
+ ?? ((config.memoryModels?.extract || config.memoryModels?.consolidation)
191
+ && !options.comboAttempt && !options.compactionRoutingOverride && inboundWire === "responses"
192
+ ? detectMemoryModelPhase(body, req.headers, { transport: options.inboundTransport }) ?? undefined
193
+ : undefined);
194
+ const memoryModelTarget = memoryModelPhase ? configuredMemoryModel(config, memoryModelPhase) : undefined;
195
+ // A combo child is a synthetic replay of the parent's decision: its model is already the target's
196
+ // concrete provider/model, so neither site below may rewrite or re-resolve it. It keeps the phase
197
+ // through `options.memoryModelPhase` instead, which is what applies the phase effort.
198
+ const memoryModelApplies = memoryModelTarget !== undefined && options.comboAttempt !== true;
177
199
  options.onRequestBodyParsed?.(body);
178
200
  // An effort row naming a table-less combo (`combo/x--high`) must reach the combo dispatcher
179
201
  // as its base id, so the selector is normalized here, before comboIdFromRawBody reads model.
@@ -228,11 +250,22 @@ export async function prepareResponsesRequest(
228
250
  // hops — which only exist inside that loop — are unreachable (#4129). Rewrite the selector
229
251
  // here instead, before comboIdFromRawBody reads `model`, and identify the combo by CONFIG
230
252
  // LOOKUP so the check can never observe a one-candidate collapse.
253
+ // A memory target that names a combo has to reach the combo dispatcher as `model`, or its own
254
+ // failover loop is unreachable (#4129) — the same reason the shadow intercept rewrites its combo
255
+ // target here. Every other target is resolved at the late site, where the admission scope exists.
256
+ let memoryModelComboRouted = false;
257
+ if (memoryModelApplies && memoryModelTarget && body && typeof body === "object" && !Array.isArray(body)) {
258
+ const memoryComboId = resolveComboId(config, memoryModelTarget.model);
259
+ if (memoryComboId && Object.hasOwn(config.combos ?? {}, memoryComboId)) {
260
+ memoryModelComboRouted = true;
261
+ (body as Record<string, unknown>).model = memoryModelTarget.model;
262
+ }
263
+ }
231
264
  let shadowCallIntercepted = false;
232
265
  // A spawned sub-agent turn names its model on purpose; gpt-6-luna is both the helper
233
266
  // slug and a default sub-agent model, so neither intercept site may rewrite that turn.
234
267
  const threadSpawn = isThreadSpawnRequest(req.headers);
235
- if (!options.comboAttempt && !options.compactionRoutingOverride && !threadSpawn && body && typeof body === "object" && !Array.isArray(body)) {
268
+ if (!options.comboAttempt && !options.compactionRoutingOverride && !threadSpawn && !memoryModelApplies && body && typeof body === "object" && !Array.isArray(body)) {
236
269
  const shadowIntercept = config.shadowCallIntercept;
237
270
  const rawShadowModel = (body as { model?: unknown }).model;
238
271
  if (shadowIntercept?.enabled && shadowIntercept.model && typeof rawShadowModel === "string"
@@ -258,6 +291,9 @@ export async function prepareResponsesRequest(
258
291
  // Concrete combo child selectors no longer match the shadow source model. Carry the
259
292
  // interception decision explicitly so provider-specific helper isolation still applies.
260
293
  shadowCallIntercepted,
294
+ // Same handoff for a memory phase whose target is a combo: the child keeps the phase's effort
295
+ // override and stays out of the parent conversation.
296
+ memoryModelPhase: memoryModelComboRouted ? memoryModelPhase : undefined,
261
297
  // The original request body was accepted above. Combo children are synthetic
262
298
  // replays and must not repeat the caller-owned timeout transition.
263
299
  onRequestBodyRead: undefined,
@@ -314,6 +350,15 @@ export async function prepareResponsesRequest(
314
350
  );
315
351
  }
316
352
 
353
+ const skillsSnapshotScopeKey = resolveSkillsSnapshotScopeKey({
354
+ req,
355
+ config,
356
+ admission: options.admission,
357
+ promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort,
358
+ });
359
+ // Substitutes a known snapshot now; a new catalog is only stored once the request is prepared.
360
+ const commitSkillsSnapshot = snapshotSkillsCatalogInBody(body, skillsSnapshotScopeKey, config);
361
+
317
362
  let parsed: OcxParsedRequest;
318
363
  let toolBridgeMaps: ReturnType<typeof buildToolBridgeMaps>;
319
364
  try {
@@ -381,6 +426,10 @@ export async function prepareResponsesRequest(
381
426
  }
382
427
  if (cursorClientThreadId) parsed._cursorClientThreadId = cursorClientThreadId;
383
428
  if (options.shadowCallIntercepted === true) parsed._cursorIsolateConversation = true;
429
+ if (options.memoryModelPhase !== undefined) {
430
+ parsed._memoryModelPhase = options.memoryModelPhase;
431
+ parsed._cursorIsolateConversation = true;
432
+ }
384
433
  } catch (err) {
385
434
  if (isTranslatorBudgetExceededError(err)) {
386
435
  return formatErrorResponse(413, "request_too_large", "request translation buffer exceeded the safe limit", {
@@ -484,9 +533,28 @@ export async function prepareResponsesRequest(
484
533
  : parsed._compactionRequest === true
485
534
  ? routeCompactionModel(config, modelId, evidenceFromBody(parsed._rawBody))
486
535
  : routeModel(config, modelId, evidenceFromBody(parsed._rawBody)));
536
+ // The phase's destination. Resolved through the admission-scoped resolver every other route
537
+ // uses, and it fails closed exactly like the shadow target: falling back to the native model
538
+ // would spend the quota the operator routed away from, without their choosing it.
539
+ let memoryRoute: RouteResult | undefined;
540
+ if (memoryModelApplies && memoryModelPhase && memoryModelTarget) {
541
+ const memoryTarget = resolveChosenTarget(memoryModelTarget.model, resolveRoute);
542
+ if ("unavailable" in memoryTarget) {
543
+ logCtx.errorCode = MEMORY_MODEL_TARGET_UNAVAILABLE_CODE;
544
+ return memoryModelTargetUnavailableResponse(memoryModelPhase);
545
+ }
546
+ credentialDomainWasRewritten = true;
547
+ parsed.modelId = memoryModelTarget.model;
548
+ if (parsed._rawBody && typeof parsed._rawBody === "object") {
549
+ (parsed._rawBody as { model?: string }).model = memoryModelTarget.model;
550
+ }
551
+ parsed._memoryModelPhase = memoryModelPhase;
552
+ parsed._cursorIsolateConversation = true;
553
+ memoryRoute = memoryTarget.route;
554
+ }
487
555
  const _sci = config.shadowCallIntercept;
488
556
  let shadowRoute: RouteResult | undefined;
489
- if (!options.compactionRoutingOverride && !threadSpawn && _sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
557
+ if (!memoryRoute && !options.memoryModelPhase && !options.compactionRoutingOverride && !threadSpawn && _sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
490
558
  const sourcePrefix = shadowSourceModelPrefix(parsed.modelId, _sci.sourceModels)!;
491
559
  let sourceIdentity = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourcePrefix };
492
560
  try {
@@ -523,7 +591,20 @@ export async function prepareResponsesRequest(
523
591
  }
524
592
  }
525
593
  if (parsed._compactionRequest === true || options.compactionRoutingOverride) parsed._cursorIsolateConversation = true;
526
- route = shadowRoute ?? resolveRoute(parsed.modelId);
594
+ route = memoryRoute ?? shadowRoute ?? resolveRoute(parsed.modelId);
595
+ // Name the phase in the persisted route decision, so the request log says why this turn went to
596
+ // the memory destination instead of leaving it looking like a plain user selection. Set here, on
597
+ // the resolved route, so a combo child's own route carries it too.
598
+ if (parsed._memoryModelPhase) {
599
+ const reason = memoryModelRouteReason(parsed._memoryModelPhase);
600
+ route.routeReason = reason;
601
+ if (route.routeDecision) {
602
+ route.routeDecision = {
603
+ ...route.routeDecision,
604
+ selected: { ...route.routeDecision.selected, reason },
605
+ };
606
+ }
607
+ }
527
608
  if (options.compactionRoutingOverride && !compactionRoutingKeepsProviderIdentity(config, options.compactionRoutingOverride, route)) {
528
609
  credentialDomainWasRewritten = true;
529
610
  // The destination does not share the conversation's credential domain, so it can neither
@@ -1263,6 +1344,7 @@ export async function prepareResponsesRequest(
1263
1344
  ? admissionState.authCtx.accountId
1264
1345
  : config.activeCodexAccountId ?? null;
1265
1346
 
1347
+ commitSkillsSnapshot?.();
1266
1348
  return {
1267
1349
  inboundWire,
1268
1350
  translatorBudget,
@@ -24,6 +24,7 @@ import {
24
24
  hasAnthropicFailoverQuorum,
25
25
  } from "../../oauth/anthropic-routing";
26
26
  import {
27
+ OAuthAccountPausedError,
27
28
  getValidAccessSnapshotForAccount,
28
29
  forceRefreshOAuthAccessSnapshot,
29
30
  getValidAccessTokenSnapshot,
@@ -697,6 +698,9 @@ export async function prepareResponsesTransport(
697
698
  `${redactSecretString(err.message)}. Remove or reconfigure provider '${safeProviderName}' in the OpenCodex configuration.`,
698
699
  );
699
700
  }
701
+ if (err instanceof OAuthAccountPausedError) {
702
+ return formatErrorResponse(403, "permission_error", publicOAuthAuthenticationErrorMessage(err));
703
+ }
700
704
  return formatErrorResponse(401, "authentication_error", publicOAuthAuthenticationErrorMessage(err));
701
705
  }
702
706
  }
@@ -551,7 +551,7 @@ export async function executeResponsesRunTurn(
551
551
  return retryQueue.stream();
552
552
  };
553
553
 
554
- const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps;
554
+ const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames } = toolBridgeMaps;
555
555
  const enforceDeclaredToolNames = inboundWire !== "chat" && inboundWire !== "anthropic";
556
556
  const classifyUndeclaredFirstTool = (
557
557
  event: AdapterEvent,
@@ -559,7 +559,7 @@ export async function executeResponsesRunTurn(
559
559
  if (!enforceDeclaredToolNames || event.type !== "tool_call_start") return undefined;
560
560
  // This tool is declared to the adapter by the private search loop.
561
561
  if (wsPlan && event.name === WEB_SEARCH_TOOL_NAME) return undefined;
562
- const effectiveName = normalizeDeclaredToolName(event.name, declaredToolNames);
562
+ const effectiveName = normalizeDeclaredToolName(event.name, declaredToolNames, undefined, bareCustomToolNames);
563
563
  if (declaredToolNames.has(effectiveName)) return undefined;
564
564
  return {
565
565
  type: "error",
@@ -574,6 +574,7 @@ export async function executeResponsesRunTurn(
574
574
  if (preflight.replayUnsafe || preflight.error?.status !== 429
575
575
  || preflight.error.code === SEND_BUDGET_EXHAUSTED_CODE) return;
576
576
  const { httpStatus, error } = adapterFailureFromEvent(preflight.error);
577
+ transportState.bindKeyUsageFromBridge(preflight.error.usage);
577
578
  cancelResponseCompletion();
578
579
  runTurnAbort.abort();
579
580
  queue.close();
@@ -672,6 +673,7 @@ export async function executeResponsesRunTurn(
672
673
  stallTimeoutSec,
673
674
  hideThinkingSummary: parsed.options.hideThinkingSummary,
674
675
  declaredToolNames,
676
+ bareCustomToolNames,
675
677
  enforceDeclaredToolNames,
676
678
  toolParameterSchemas,
677
679
  ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
@@ -814,6 +816,7 @@ export async function executeResponsesRunTurn(
814
816
  enforceDeclaredToolNames,
815
817
  toolParameterSchemas,
816
818
  freeformToolNames,
819
+ bareCustomToolNames,
817
820
  toolSearchToolNames,
818
821
  ...(routedCompaction ? { compaction: true } : {}),
819
822
  onProviderState: state => { providerState = state; },
@@ -16,16 +16,21 @@ export const INTERCEPT_TARGET_UNAVAILABLE_CODE = "intercept_target_unavailable";
16
16
  /** Non-retryable: the target stays unavailable until the operator changes the configuration. */
17
17
  export const INTERCEPT_TARGET_UNAVAILABLE_STATUS = 409;
18
18
 
19
- export type ShadowTargetResolution = { route: RouteResult } | { unavailable: string };
19
+ export type ChosenTargetResolution = { route: RouteResult } | { unavailable: string };
20
+ export type ShadowTargetResolution = ChosenTargetResolution;
20
21
 
21
22
  /**
22
- * Resolve the configured target. Admission-scope refusals, exhausted combos and policy
23
+ * Resolve one operator-chosen target. Admission-scope refusals, exhausted combos and policy
23
24
  * evaluations keep their existing responses, so they are rethrown to the caller's handler.
25
+ *
26
+ * Shared by the shadow-call intercept and the memory-model routing: both name a single destination
27
+ * whose unavailability must not be papered over by the router's terminal default-provider
28
+ * fallback. `shadowCallTargetsIntersect` and the memory setting are the two callers.
24
29
  */
25
- export function resolveShadowCallTarget(
30
+ export function resolveChosenTarget(
26
31
  model: string,
27
32
  resolve: (model: string) => RouteResult,
28
- ): ShadowTargetResolution {
33
+ ): ChosenTargetResolution {
29
34
  let route: RouteResult;
30
35
  try {
31
36
  route = resolve(model);
@@ -44,6 +49,14 @@ export function resolveShadowCallTarget(
44
49
  return { route };
45
50
  }
46
51
 
52
+ /** The shadow-call name for the shared resolver, kept because that is the surface's own vocabulary. */
53
+ export function resolveShadowCallTarget(
54
+ model: string,
55
+ resolve: (model: string) => RouteResult,
56
+ ): ShadowTargetResolution {
57
+ return resolveChosenTarget(model, resolve);
58
+ }
59
+
47
60
  const warnedTargets = new Set<string>();
48
61
 
49
62
  export function interceptTargetUnavailableResponse(model: string, detail: string): Response {
@@ -111,6 +111,17 @@ export async function executeResponsesSidecars(
111
111
  cancelResponseCompletion,
112
112
  } = responseEffects;
113
113
 
114
+ // Resolving the OpenAI search credential may hold the account's sole
115
+ // cooldown-recovery probe lease. A streamed sidecar result keeps it until the
116
+ // stream settles — completion or client cancel — so a later in-stream search
117
+ // outcome can still clear the cooldown; a response with no live body is
118
+ // terminal, so the lease is handed back before returning it. A recorded
119
+ // search outcome already settled the lease, making each release a
120
+ // generation-bound no-op.
121
+ const releaseSearchProbeLease = (): void => {
122
+ openAiSidecar?.releaseProbeLease?.();
123
+ };
124
+
114
125
 
115
126
  // Tool results are PAIRED by call_id. parseRequest writes it into OcxToolResultMessage.toolCallId
116
127
  // (parser.ts:738/752) without validating it, because inputItemSchema's permissive catch-all
@@ -133,6 +144,8 @@ export async function executeResponsesSidecars(
133
144
  || (message as { toolCallId: string }).toolCallId.length === 0),
134
145
  );
135
146
  if (unpaired) {
147
+ // Local validation has no running sidecar body to own this lease.
148
+ releaseSearchProbeLease();
136
149
  // Never interpolate the tool output: this message reaches the client and the logs.
137
150
  return formatErrorResponse(
138
151
  400,
@@ -330,6 +343,7 @@ export async function executeResponsesSidecars(
330
343
  // the bridge entirely so enabling the feature doesn't break ordinary non-streaming traffic.
331
344
  if (!parsed.stream) {
332
345
  if (imgPlan) {
346
+ releaseSearchProbeLease();
333
347
  return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true");
334
348
  }
335
349
  // Video-only: skip bridge for non-streaming requests
@@ -431,11 +445,12 @@ export async function executeResponsesSidecars(
431
445
  if (imgResponse.body) {
432
446
  const imgTurnAc = new AbortController();
433
447
  imgTurnAc.signal.addEventListener("abort", cancelResponseCompletion, { once: true });
434
- return new Response(trackStreamLifetime(imgResponse.body, imgTurnAc, undefined, options.turnAdmissionLease), {
448
+ return new Response(trackStreamLifetime(imgResponse.body, imgTurnAc, releaseSearchProbeLease, options.turnAdmissionLease), {
435
449
  status: imgResponse.status,
436
450
  headers: imgResponse.headers,
437
451
  });
438
452
  }
453
+ releaseSearchProbeLease();
439
454
  return imgResponse;
440
455
  } // end else (streaming bridge)
441
456
  }
@@ -511,11 +526,12 @@ export async function executeResponsesSidecars(
511
526
  if (wsResponse.body) {
512
527
  const wsTurnAc = new AbortController();
513
528
  wsTurnAc.signal.addEventListener("abort", cancelResponseCompletion, { once: true });
514
- return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc, undefined, options.turnAdmissionLease), {
529
+ return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc, releaseSearchProbeLease, options.turnAdmissionLease), {
515
530
  status: wsResponse.status,
516
531
  headers: wsResponse.headers,
517
532
  });
518
533
  }
534
+ releaseSearchProbeLease();
519
535
  return wsResponse;
520
536
  }
521
537
 
@@ -0,0 +1,239 @@
1
+ /**
2
+ * runtime skills catalog session snapshotting (#5569).
3
+ *
4
+ * Preserves the Anthropic/LLM prompt cache prefix across turns by freezing
5
+ * incoming <skills_instructions> for the duration of a trustworthy session.
6
+ * Gated by config `skills.catalog_refresh`: "per_session" (default) or "per_turn".
7
+ *
8
+ * Lifecycle & Bounds:
9
+ * - 4 hours idle TTL (sliding on access)
10
+ * - 1,000 maximum tracked sessions (LRU eviction)
11
+ * - 512 KiB maximum per snapshotted skills block
12
+ * - 8 MiB global retained byte bound across all sessions
13
+ */
14
+ import type { OcxConfig, SkillsCatalogRefresh } from "../../types/config";
15
+ import { isApiAuthRequired, resolveContextPrincipal, type DataPlaneAdmission } from "../auth-cors";
16
+ import {
17
+ reasoningReplayConversationIdFromResponsesRequest,
18
+ sessionIdHeaderFromRequest,
19
+ } from "../request-log-conversation";
20
+
21
+ const SKILLS_BLOCK_GLOBAL_REGEX = /<skills_instructions>([\s\S]*?)<\/skills_instructions>/g;
22
+
23
+ /** Maximum distinct sessions tracked in the memory LRU. */
24
+ export const MAX_SNAPSHOT_SESSIONS = 1000;
25
+ /** Slide expiry after 4 hours of inactivity. */
26
+ export const SNAPSHOT_TTL_MS = 4 * 60 * 60 * 1000;
27
+ /** Bounded byte ceiling per snapshotted skills block (512 KiB). */
28
+ export const MAX_SKILLS_BLOCK_BYTES = 512 * 1024;
29
+ /** Global retained byte bound across all tracked sessions (8 MiB). */
30
+ export const MAX_TOTAL_RETAINED_BYTES = 8 * 1024 * 1024;
31
+
32
+ interface SnapshotEntry {
33
+ skillsBlock: string; // The full <skills_instructions>...</skills_instructions> block
34
+ byteLength: number;
35
+ lastAccessed: number;
36
+ }
37
+
38
+ const snapshotCache = new Map<string, SnapshotEntry>();
39
+ let totalRetainedBytes = 0;
40
+
41
+ function evictOldestEntry(): boolean {
42
+ const oldest = snapshotCache.entries().next().value;
43
+ if (!oldest) return false;
44
+ const [key, entry] = oldest;
45
+ totalRetainedBytes -= entry.byteLength;
46
+ snapshotCache.delete(key);
47
+ return true;
48
+ }
49
+
50
+ export function resolveSkillsCatalogRefresh(config: OcxConfig | undefined): SkillsCatalogRefresh {
51
+ const configured = config?.skills?.catalog_refresh;
52
+ if (configured === "per_turn") return "per_turn";
53
+ return "per_session";
54
+ }
55
+
56
+ export interface ResolveSkillsSessionScopeInput {
57
+ req: Request;
58
+ config: OcxConfig;
59
+ admission?: DataPlaneAdmission;
60
+ cursorConversationId?: string;
61
+ promptCacheKeyIsSharedCohort?: boolean;
62
+ }
63
+
64
+ /**
65
+ * The principal a snapshot is scoped to. Without a named principal, only a server that requires
66
+ * no data-plane auth may share by conversation id (loopback admission, or an internal caller that
67
+ * passed none on such a server): its callers already share one trust domain, which on a no-auth
68
+ * server bound beyond loopback includes remote callers. Any other anonymous request gets no
69
+ * snapshot.
70
+ */
71
+ function snapshotPrincipal(input: ResolveSkillsSessionScopeInput): string | null | undefined {
72
+ const principal = resolveContextPrincipal(input.req, input.config, input.admission);
73
+ if (principal) return principal;
74
+ if (input.admission) return input.admission.kind === "loopback" ? null : undefined;
75
+ return isApiAuthRequired(input.config) ? undefined : null;
76
+ }
77
+
78
+ /**
79
+ * Resolves a trustworthy cache key for skills catalog snapshotting.
80
+ * Returns null if no specific, reliable thread/session identity is available,
81
+ * or if the identity comes from a shared cohort fallback.
82
+ */
83
+ export function resolveSkillsSnapshotScopeKey(input: ResolveSkillsSessionScopeInput): string | null {
84
+ if (input.promptCacheKeyIsSharedCohort === true) {
85
+ return null;
86
+ }
87
+
88
+ const parentThread = input.req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
89
+ const ownThreadId = input.req.headers.get("thread-id")?.trim() || undefined;
90
+ const cursorId = input.cursorConversationId?.trim() || undefined;
91
+
92
+ // When a parent thread is present, subagents/children may share a root session-id header.
93
+ // To prevent cross-thread/sibling collapse or parent-level caching, require an explicit
94
+ // own thread-id (or cursor id). If parentThread is present without an own child thread,
95
+ // bypass snapshotting completely.
96
+ if (parentThread) {
97
+ const childId = ownThreadId ?? cursorId;
98
+ if (!childId || childId === parentThread) {
99
+ return null;
100
+ }
101
+ const qualifiedId = `${parentThread}\u0000${childId}`;
102
+ const principal = snapshotPrincipal(input);
103
+ if (principal === undefined) return null;
104
+ return JSON.stringify(["skills_catalog_snapshot_v1", principal, qualifiedId]);
105
+ }
106
+
107
+ // Standalone conversation (no parent thread)
108
+ const standaloneId = reasoningReplayConversationIdFromResponsesRequest({
109
+ threadIdHeader: ownThreadId,
110
+ cursorConversationId: cursorId,
111
+ sessionIdHeader: sessionIdHeaderFromRequest(input.req.headers),
112
+ });
113
+ if (!standaloneId) {
114
+ return null;
115
+ }
116
+ const principal = snapshotPrincipal(input);
117
+ if (principal === undefined) return null;
118
+ return JSON.stringify(["skills_catalog_snapshot_v1", principal, standaloneId]);
119
+ }
120
+
121
+ /** One text slot that may carry a catalog: `instructions` or a developer/system text part. */
122
+ interface CatalogSlot {
123
+ text: string;
124
+ write(next: string): void;
125
+ }
126
+
127
+ /** Every developer/system text slot, walked the same way the replacement writes. */
128
+ function catalogSlots(body: Record<string, unknown>): CatalogSlot[] {
129
+ const slots: CatalogSlot[] = [];
130
+ if (typeof body.instructions === "string") {
131
+ slots.push({ text: body.instructions, write: next => { body.instructions = next; } });
132
+ }
133
+ if (!Array.isArray(body.input)) return slots;
134
+ for (const item of body.input) {
135
+ if (!item || typeof item !== "object") continue;
136
+ const it = item as Record<string, unknown>;
137
+ // Restrict message item type: must be undefined or "message", so role-like tool objects are untouched
138
+ if (it.type !== undefined && it.type !== "message") continue;
139
+ // Only developer and system content is inspected/transformed
140
+ if (it.role !== "developer" && it.role !== "system") continue;
141
+ const content = it.content;
142
+ if (typeof content === "string") {
143
+ slots.push({ text: content, write: next => { it.content = next; } });
144
+ } else if (Array.isArray(content)) {
145
+ for (const part of content) {
146
+ if (!part || typeof part !== "object") continue;
147
+ const p = part as Record<string, unknown>;
148
+ // Restrict text parts to known text / input_text
149
+ if (p.type !== "text" && p.type !== "input_text") continue;
150
+ if (typeof p.text === "string") slots.push({ text: p.text, write: next => { p.text = next; } });
151
+ }
152
+ }
153
+ }
154
+ return slots;
155
+ }
156
+
157
+ function liveSnapshot(scopeKey: string, now: number): SnapshotEntry | undefined {
158
+ const existing = snapshotCache.get(scopeKey);
159
+ if (!existing) return undefined;
160
+ if (now - existing.lastAccessed > SNAPSHOT_TTL_MS) {
161
+ totalRetainedBytes -= existing.byteLength;
162
+ snapshotCache.delete(scopeKey);
163
+ return undefined;
164
+ }
165
+ existing.lastAccessed = now;
166
+ // Refresh Map order for true LRU behavior
167
+ snapshotCache.delete(scopeKey);
168
+ snapshotCache.set(scopeKey, existing);
169
+ return existing;
170
+ }
171
+
172
+ function storeSnapshot(scopeKey: string, skillsBlock: string, now: number): void {
173
+ const blockBytes = Buffer.byteLength(skillsBlock, "utf8");
174
+ if (blockBytes > MAX_SKILLS_BLOCK_BYTES || blockBytes > MAX_TOTAL_RETAINED_BYTES) return;
175
+ // A concurrent request of the same conversation may have stored first; the first catalog wins.
176
+ if (snapshotCache.has(scopeKey)) return;
177
+ // Evict oldest entries until under count ceiling AND under global byte ceiling
178
+ while (
179
+ (snapshotCache.size >= MAX_SNAPSHOT_SESSIONS || totalRetainedBytes + blockBytes > MAX_TOTAL_RETAINED_BYTES)
180
+ && snapshotCache.size > 0
181
+ ) {
182
+ if (!evictOldestEntry()) break;
183
+ }
184
+ if (totalRetainedBytes + blockBytes > MAX_TOTAL_RETAINED_BYTES) return;
185
+ snapshotCache.set(scopeKey, { skillsBlock, byteLength: blockBytes, lastAccessed: now });
186
+ totalRetainedBytes += blockBytes;
187
+ }
188
+
189
+ /**
190
+ * Reuses the session's snapshotted <skills_instructions> in developer/system content, preserving
191
+ * the prompt cache prefix across turns. User and assistant messages and tool calls are never
192
+ * modified.
193
+ *
194
+ * Only a body with exactly one catalog block across all of those slots takes part: with two or
195
+ * more there is no way to tell which one the snapshot stands for, so the body passes through
196
+ * untouched rather than rewriting every block to one catalog.
197
+ *
198
+ * A known snapshot is substituted at once, so parsing and the verbatim passthrough body both see
199
+ * it. A new catalog is only stored through the returned commit, which the caller runs once the
200
+ * request has passed parsing and admission, so a rejected first request pins nothing.
201
+ */
202
+ export function snapshotSkillsCatalogInBody(
203
+ body: unknown,
204
+ scopeKey: string | null,
205
+ config: OcxConfig,
206
+ now: number = Date.now(),
207
+ ): (() => void) | undefined {
208
+ if (!scopeKey) return undefined;
209
+ if (resolveSkillsCatalogRefresh(config) === "per_turn") return undefined;
210
+ if (!body || typeof body !== "object" || Array.isArray(body)) return undefined;
211
+
212
+ let found: { slot: CatalogSlot; block: string } | undefined;
213
+ let blocks = 0;
214
+ for (const slot of catalogSlots(body as Record<string, unknown>)) {
215
+ if (!slot.text.includes("<skills_instructions>")) continue;
216
+ for (const match of slot.text.matchAll(SKILLS_BLOCK_GLOBAL_REGEX)) {
217
+ blocks++;
218
+ found ??= { slot, block: match[0] };
219
+ }
220
+ }
221
+ if (blocks !== 1 || !found) return undefined;
222
+
223
+ const existing = liveSnapshot(scopeKey, now);
224
+ if (existing) {
225
+ const { slot, block } = found;
226
+ const at = slot.text.indexOf(block);
227
+ slot.write(slot.text.slice(0, at) + existing.skillsBlock + slot.text.slice(at + block.length));
228
+ return undefined;
229
+ }
230
+ const incoming = found.block;
231
+ return () => storeSnapshot(scopeKey, incoming, now);
232
+ }
233
+
234
+ /** Test helpers */
235
+ export function resetSkillsSnapshotCacheForTests(): void {
236
+ snapshotCache.clear();
237
+ totalRetainedBytes = 0;
238
+ }
239
+