@bitkyc08/opencodex 2.64.0 → 2.65.0-preview.20260925

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 (208) hide show
  1. package/README.md +28 -25
  2. package/bin/ocx.mjs +16 -2
  3. package/gui/dist/assets/App-8NMiZxT0.css +1 -0
  4. package/gui/dist/assets/App-CsYvvpr3.js +50 -0
  5. package/gui/dist/assets/Tray-DUvc_Wul.js +1 -0
  6. package/gui/dist/assets/{index-DiBRuK-d.css → index--EWgGQvZ.css} +1 -1
  7. package/gui/dist/assets/{index-SggB6t3z.js → index-BB0iHG8-.js} +12 -12
  8. package/gui/dist/assets/tray-data-f0lTZ4sF.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/package.json +1 -4
  11. package/src/adapters/anthropic.ts +30 -1
  12. package/src/adapters/claude-cli/adapter.ts +218 -0
  13. package/src/adapters/claude-cli/profiles.ts +38 -0
  14. package/src/adapters/codebuddy/profiles.ts +2 -0
  15. package/src/adapters/coding-agent/profile.ts +12 -4
  16. package/src/adapters/coding-agent/turn.ts +16 -6
  17. package/src/adapters/command-code-tool-text.ts +133 -23
  18. package/src/adapters/cursor/catalog.ts +4 -8
  19. package/src/adapters/cursor/discovery.ts +10 -5
  20. package/src/adapters/cursor/effort-map.ts +2 -6
  21. package/src/adapters/cursor/envelope-echo.ts +10 -5
  22. package/src/adapters/cursor/request-builder.ts +9 -4
  23. package/src/adapters/cursor/thread-continuity.ts +53 -0
  24. package/src/adapters/cursor.ts +29 -13
  25. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  26. package/src/adapters/google-tool-schema.ts +26 -3
  27. package/src/adapters/identity.ts +214 -2
  28. package/src/adapters/openai-chat/passthrough.ts +1 -0
  29. package/src/adapters/openai-chat/serialized-tool-call-content.ts +569 -0
  30. package/src/adapters/openai-chat.ts +43 -15
  31. package/src/adapters/openai-responses/canonical-forward.ts +17 -10
  32. package/src/adapters/openai-responses/passthrough.ts +19 -4
  33. package/src/adapters/openai-responses/request-strips.ts +25 -0
  34. package/src/adapters/openai-responses/web-search.ts +22 -4
  35. package/src/adapters/qoder/profiles.ts +2 -0
  36. package/src/adapters/registry.ts +8 -0
  37. package/src/adapters/run-turn-queue.ts +7 -0
  38. package/src/bridge/response-json.ts +6 -4
  39. package/src/bridge/sse.ts +7 -5
  40. package/src/claude/alias.ts +73 -26
  41. package/src/claude/context-windows.ts +15 -6
  42. package/src/claude/desktop-3p-library.ts +3 -2
  43. package/src/claude/desktop-3p.ts +1 -1
  44. package/src/claude/desktop-first-party.ts +63 -22
  45. package/src/claude/desktop-picker-profile.ts +302 -0
  46. package/src/claude/desktop-picker.ts +365 -0
  47. package/src/claude/desktop-risk.ts +20 -0
  48. package/src/claude/gateway-cache.ts +13 -4
  49. package/src/claude/inbound.ts +5 -2
  50. package/src/claude/intercept/connect-proxy.ts +106 -29
  51. package/src/claude/intercept/local-ca.ts +76 -18
  52. package/src/claude/intercept/model-bindings.ts +145 -0
  53. package/src/claude/intercept/picker-bootstrap.ts +141 -0
  54. package/src/claude/intercept/picker-ca.ts +126 -0
  55. package/src/claude/intercept/picker-listener.ts +213 -0
  56. package/src/claude/intercept/picker-models.ts +101 -0
  57. package/src/claude/intercept/picker-runtime.ts +330 -0
  58. package/src/claude/intercept/picker-trust.ts +125 -0
  59. package/src/claude/intercept/runtime.ts +149 -2
  60. package/src/claude/model-info.ts +18 -4
  61. package/src/cli/agent.ts +16 -2
  62. package/src/cli/capabilities.ts +71 -2
  63. package/src/cli/claude-desktop.ts +319 -33
  64. package/src/cli/claude.ts +15 -7
  65. package/src/cli/codex-shim-autorestore.ts +1 -0
  66. package/src/cli/dispatch.ts +29 -23
  67. package/src/cli/effort.ts +16 -6
  68. package/src/cli/ensure-desired-integrations.ts +29 -4
  69. package/src/cli/ready.ts +1 -1
  70. package/src/cli/registry.ts +8 -0
  71. package/src/cli/restart-scope.ts +9 -1
  72. package/src/cli/runtime-api.ts +17 -0
  73. package/src/cli/system-command.ts +11 -12
  74. package/src/client/connect.ts +12 -2
  75. package/src/client/state.ts +39 -1
  76. package/src/clients/config-export.ts +57 -9
  77. package/src/codex/auth-context.ts +51 -11
  78. package/src/codex/catalog/derive-entry.ts +5 -5
  79. package/src/codex/catalog/gather-capture.ts +25 -6
  80. package/src/codex/catalog/metadata.ts +3 -3
  81. package/src/codex/catalog/parsing.ts +39 -1
  82. package/src/codex/catalog/provider-models.ts +8 -6
  83. package/src/codex/catalog/retained-sync.ts +17 -7
  84. package/src/codex/catalog/sync.ts +2 -2
  85. package/src/codex/desktop-app-restart.ts +8 -0
  86. package/src/codex/desktop-switches.ts +9 -1
  87. package/src/codex/home.ts +43 -4
  88. package/src/codex/inject/config-toml.ts +137 -0
  89. package/src/codex/inject/plan.ts +23 -0
  90. package/src/codex/inject/remove.ts +21 -1
  91. package/src/codex/inject.ts +63 -8
  92. package/src/codex/journal.ts +36 -0
  93. package/src/codex/main-account-hard-lock.ts +14 -1
  94. package/src/codex/main-account-policy-wait.ts +109 -0
  95. package/src/codex/native-profile-startup.ts +24 -6
  96. package/src/codex/native-residue.ts +28 -10
  97. package/src/codex/quota-types.ts +8 -1
  98. package/src/codex/quota.ts +49 -7
  99. package/src/codex/runtime.ts +31 -10
  100. package/src/combos/failover.ts +7 -6
  101. package/src/combos/resolve.ts +91 -14
  102. package/src/combos/types.ts +18 -1
  103. package/src/config/load-degrade.ts +2 -0
  104. package/src/config/schema/config-schema.ts +21 -2
  105. package/src/config/schema/leaf-validators.ts +6 -3
  106. package/src/config/subagent-models.ts +3 -1
  107. package/src/generated/compatibility-version.json +275 -163
  108. package/src/images/artifacts.ts +14 -0
  109. package/src/integrations/owned-refresh.ts +1 -1
  110. package/src/integrations/ownership-policy.ts +32 -1
  111. package/src/integrations/state.ts +3 -1
  112. package/src/integrations/writer.ts +9 -0
  113. package/src/lib/config-ownership.ts +3 -0
  114. package/src/lib/errors.ts +87 -0
  115. package/src/lib/package-tree-integrity.ts +1 -1
  116. package/src/lib/request-execution-budget.ts +42 -14
  117. package/src/lib/request-resend-gate.ts +4 -3
  118. package/src/lib/retry-delay.ts +7 -1
  119. package/src/lib/tool-envelope-echo-filter.ts +236 -0
  120. package/src/lib/upstream-retry.ts +20 -12
  121. package/src/oauth/index.ts +1 -0
  122. package/src/oauth/kiro-credentials.ts +14 -1
  123. package/src/oauth/login-cli.ts +1 -0
  124. package/src/providers/derive.ts +4 -0
  125. package/src/providers/fast-opt-in.ts +31 -0
  126. package/src/providers/model-rename-fields.ts +2 -0
  127. package/src/providers/provider-id-rewrite.ts +2 -0
  128. package/src/providers/quota/vendor-probes-key.ts +8 -2
  129. package/src/providers/registry/entries-core.ts +45 -2
  130. package/src/providers/registry/entries-extended.ts +99 -12
  131. package/src/providers/registry/model-ids.ts +2 -0
  132. package/src/providers/registry/types.ts +7 -1
  133. package/src/providers/resolved-model-policy.ts +11 -4
  134. package/src/providers/service-tier.ts +10 -3
  135. package/src/responses/code-mode-helper-compat.ts +3 -0
  136. package/src/responses/parser.ts +45 -3
  137. package/src/router.ts +6 -0
  138. package/src/server/auth-cors.ts +8 -0
  139. package/src/server/chat-completions.ts +3 -1
  140. package/src/server/claude-messages.ts +69 -14
  141. package/src/server/grok-upstream-envelope-echo.ts +120 -0
  142. package/src/server/index/claude-intercept-lifecycle.ts +24 -0
  143. package/src/server/index/serve-options.ts +2 -2
  144. package/src/server/index.ts +7 -0
  145. package/src/server/management/agent-settings-routes.ts +204 -112
  146. package/src/server/management/claude-desktop-picker-routes.ts +128 -0
  147. package/src/server/management/combo-routes.ts +59 -2
  148. package/src/server/management/config-routes.ts +81 -39
  149. package/src/server/management/context.ts +3 -0
  150. package/src/server/management/native-integration-routes.ts +169 -93
  151. package/src/server/management/provider-routes.ts +46 -4
  152. package/src/server/management/route-registry.ts +10 -0
  153. package/src/server/management/routing-profile-routes.ts +9 -0
  154. package/src/server/management/sidebar-routes.ts +52 -0
  155. package/src/server/management-api.ts +2 -0
  156. package/src/server/proxy-liveness.ts +38 -3
  157. package/src/server/request-log-account-rotation.ts +53 -0
  158. package/src/server/request-log.ts +3 -16
  159. package/src/server/responses/adapter-dispatch.ts +17 -1
  160. package/src/server/responses/agent-task-recovery.ts +49 -26
  161. package/src/server/responses/codex-ws-exchange.ts +18 -7
  162. package/src/server/responses/codex-ws-wire.ts +27 -4
  163. package/src/server/responses/core-combo.ts +21 -3
  164. package/src/server/responses/core-normalize.ts +7 -0
  165. package/src/server/responses/core-opaque-recovery.ts +3 -2
  166. package/src/server/responses/encrypted-payload.ts +15 -14
  167. package/src/server/responses/fetch-helpers.ts +1 -1
  168. package/src/server/responses/input-admission.ts +10 -6
  169. package/src/server/responses/native-response-control.ts +2 -2
  170. package/src/server/responses/passthrough-delivery.ts +182 -12
  171. package/src/server/responses/passthrough-dispatch.ts +102 -44
  172. package/src/server/responses/policy-fallback.ts +3 -0
  173. package/src/server/responses/policy-refusal.ts +67 -0
  174. package/src/server/responses/request-send-budget.ts +4 -0
  175. package/src/server/responses/run-turn-execution.ts +42 -11
  176. package/src/server/responses/ws-upstream.ts +2 -1
  177. package/src/server/system-env-shell.ts +0 -1
  178. package/src/server/system-env.ts +25 -4
  179. package/src/service/cli.ts +34 -3
  180. package/src/tray/assets/opencodex-tray-offline-update.ico +0 -0
  181. package/src/tray/assets/opencodex-tray-online-update.ico +0 -0
  182. package/src/tray/assets/opencodex-tray-warning-update.ico +0 -0
  183. package/src/tray/windows-tray.ps1 +188 -7
  184. package/src/tray/windows.ts +23 -2
  185. package/src/types/config.ts +52 -12
  186. package/src/types/provider.ts +22 -5
  187. package/src/types/tools.ts +11 -5
  188. package/src/types.ts +1 -0
  189. package/src/update/async-check.ts +109 -0
  190. package/src/update/badge.ts +11 -17
  191. package/src/update/check-types.ts +9 -0
  192. package/src/update/desktop-badge.ts +102 -0
  193. package/src/update/index.ts +48 -8
  194. package/src/update/install-detection.d.mts +29 -2
  195. package/src/update/install-detection.mjs +213 -7
  196. package/src/update/job.ts +17 -12
  197. package/src/update/notify.ts +16 -11
  198. package/src/update/pnpm-owner-worker.ts +12 -0
  199. package/src/update/refresh-scheduler.ts +141 -0
  200. package/src/usage/cost.ts +31 -4
  201. package/src/usage/expected-prices.ts +57 -0
  202. package/assets/download-linux.svg +0 -10
  203. package/assets/download-macos.svg +0 -10
  204. package/assets/download-windows.svg +0 -10
  205. package/gui/dist/assets/App-CpuDF3ci.js +0 -50
  206. package/gui/dist/assets/App-I5AnaSLh.css +0 -1
  207. package/gui/dist/assets/Tray-B4uEIa1O.js +0 -1
  208. package/gui/dist/assets/usage-companion-chart-DLbKOJml.js +0 -1
@@ -82,6 +82,9 @@ const JOURNAL_FILE_NAME = "opencodex-journal.json";
82
82
  const ROUTED_CATALOG_DESCRIPTION_PREFIX = "Routed via opencodex → ";
83
83
  const MAX_ROLLOUT_INSPECTION_BYTES = 64 * 1024 * 1024;
84
84
  const ROLLOUT_READ_CHUNK_BYTES = 64 * 1024;
85
+ // Bound one observation across every referenced rollout, not just each file.
86
+ const MAX_TOTAL_ROLLOUT_INSPECTION_BYTES = 64 * 1024 * 1024;
87
+ type RolloutInspectionBudget = { remainingBytes: number };
85
88
 
86
89
  function errorCode(error: unknown): string | undefined {
87
90
  return (error as NodeJS.ErrnoException | undefined)?.code;
@@ -424,6 +427,7 @@ function classifyPartialWrites(targetPaths: string[]): NativeRoutedResidueResult
424
427
  function classifyReferencedRollout(
425
428
  surface: "history" | "history-backup",
426
429
  reference: RolloutReference,
430
+ budget: RolloutInspectionBudget,
427
431
  ): NativeRoutedResidueResult {
428
432
  const resolved = resolveRegularFile(reference.path);
429
433
  if (resolved.kind === "absent") {
@@ -451,6 +455,14 @@ function classifyReferencedRollout(
451
455
  `referenced rollout exceeds the ${MAX_ROLLOUT_INSPECTION_BYTES} byte inspection limit`,
452
456
  );
453
457
  }
458
+ if (opened.size > budget.remainingBytes) {
459
+ return indeterminate(
460
+ surface,
461
+ resolved.path,
462
+ `referenced rollouts exceed the ${MAX_TOTAL_ROLLOUT_INSPECTION_BYTES} byte aggregate inspection limit`,
463
+ );
464
+ }
465
+ budget.remainingBytes -= opened.size;
454
466
  const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
455
467
  const buffer = Buffer.allocUnsafe(ROLLOUT_READ_CHUNK_BYTES);
456
468
  while (totalRead < opened.size) {
@@ -528,15 +540,16 @@ function classifyReferencedRollout(
528
540
  function classifyReferencedRollouts(
529
541
  surface: "history" | "history-backup",
530
542
  references: RolloutReference[],
543
+ budget: RolloutInspectionBudget,
531
544
  ): NativeRoutedResidueResult {
532
545
  for (const reference of references) {
533
- const result = classifyReferencedRollout(surface, reference);
546
+ const result = classifyReferencedRollout(surface, reference, budget);
534
547
  if (result.kind !== "clean") return result;
535
548
  }
536
549
  return { kind: "clean" };
537
550
  }
538
551
 
539
- function classifyHistoryDatabase(path: string): NativeRoutedResidueResult {
552
+ function classifyHistoryDatabase(path: string, budget: RolloutInspectionBudget): NativeRoutedResidueResult {
540
553
  const resolved = resolveRegularFile(path);
541
554
  if (resolved.kind === "absent") {
542
555
  for (const suffix of ["-wal", "-shm"]) {
@@ -576,6 +589,7 @@ function classifyHistoryDatabase(path: string): NativeRoutedResidueResult {
576
589
  rows
577
590
  .filter(row => row.model_provider !== "opencodex")
578
591
  .map(row => ({ id: row.id, path: row.rollout_path })),
592
+ budget,
579
593
  );
580
594
  if (rollouts.kind !== "clean") return rollouts;
581
595
  const after = statSync(resolved.path);
@@ -600,7 +614,7 @@ function historyBackupPath(stateDatabasePath: string): string {
600
614
  return legacy !== canonical && existsSync(legacy) ? legacy : canonical;
601
615
  }
602
616
 
603
- function classifyHistoryBackup(path: string, stateDatabasePath: string): NativeRoutedResidueResult {
617
+ function classifyHistoryBackup(path: string, stateDatabasePath: string, budget: RolloutInspectionBudget): NativeRoutedResidueResult {
604
618
  const read = readRegularFile(path);
605
619
  if (read.kind === "absent") return { kind: "clean" };
606
620
  if (read.kind === "indeterminate") return indeterminate("history-backup", path, read.reason);
@@ -630,7 +644,7 @@ function classifyHistoryBackup(path: string, stateDatabasePath: string): NativeR
630
644
  for (const entry of Object.values(validated.manifest.entries)) {
631
645
  references.push({ id: entry.id, path: entry.rolloutPath });
632
646
  }
633
- const rollouts = classifyReferencedRollouts("history-backup", references);
647
+ const rollouts = classifyReferencedRollouts("history-backup", references, budget);
634
648
  if (rollouts.kind !== "clean") return rollouts;
635
649
  return entries.length > 0
636
650
  ? { kind: "residue", surface: "history-backup", path: read.path }
@@ -672,6 +686,7 @@ export function classifyNativeRoutedResidue(): NativeRoutedResidueResult {
672
686
  journalPath,
673
687
  ...config.catalogTargets.map(target => target.path),
674
688
  ];
689
+ const budget = { remainingBytes: MAX_TOTAL_ROLLOUT_INSPECTION_BYTES };
675
690
  const classifiers = [
676
691
  () => classifyPartialWrites(atomicWriteTargets),
677
692
  () => config.classification,
@@ -679,11 +694,14 @@ export function classifyNativeRoutedResidue(): NativeRoutedResidueResult {
679
694
  ...config.catalogTargets.map(target => () => classifyCatalogLike("catalog", target.path, target.configured)),
680
695
  () => classifyCatalogLike("models-cache", modelsCachePath),
681
696
  () => classifyJournal(journalPath),
682
- () => classifyHistoryDatabase(stateDatabasePath),
683
- () => classifyHistoryBackup(historyBackupPath(stateDatabasePath), stateDatabasePath),
697
+ () => classifyHistoryDatabase(stateDatabasePath, budget),
698
+ () => classifyHistoryBackup(historyBackupPath(stateDatabasePath), stateDatabasePath, budget),
684
699
  ];
685
- const results = classifiers.map(classify => classify());
686
- return results.find(result => result.kind === "indeterminate")
687
- ?? results.find(result => result.kind === "residue")
688
- ?? { kind: "clean" };
700
+ let firstResidue: NativeRoutedResidueResult = { kind: "clean" };
701
+ for (const classify of classifiers) {
702
+ const result = classify();
703
+ if (result.kind === "indeterminate") return result;
704
+ if (result.kind === "residue" && firstResidue.kind === "clean") firstResidue = result;
705
+ }
706
+ return firstResidue;
689
707
  }
@@ -1,5 +1,12 @@
1
1
  /** Quota wire/storage shapes. This leaf must not import credential or config owners. */
2
- export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 99;
2
+ /**
3
+ * Observed usage at which new identity-matched main-account requests are refused (#5694).
4
+ *
5
+ * The policy is on by default, so this constant is what every installation without an explicit
6
+ * `codexMainAccountHardLock: false` admits against. 98 leaves one point of headroom under the
7
+ * exhausted reading Codex Desktop already treats as its own disabled send button.
8
+ */
9
+ export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 98;
3
10
 
4
11
  /**
5
12
  * How recently a 100% burst reading must have been observed to exclude an account when it
@@ -29,6 +29,8 @@ type QuotaDiskFile = {
29
29
  };
30
30
 
31
31
  type MainPolicyQuota = { identityKey: string; quota: StoredAccountQuota };
32
+ /** Fresh WHAM topology proof is consumed by the merge, never retained in a cache or DTO. */
33
+ type MainPolicyQuotaObservation = Omit<StoredAccountQuota, "updatedAt"> & { shortWindowAbsent?: true };
32
34
  let mainPolicyQuota: MainPolicyQuota | null = null;
33
35
  let diskHydrated = false;
34
36
  let persistTimer: ReturnType<typeof setTimeout> | null = null;
@@ -199,6 +201,12 @@ function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): bo
199
201
  && seconds >= MONTHLY_WINDOW_MIN_SECONDS;
200
202
  }
201
203
 
204
+ /** Same 24h short/long boundary as the parser; this includes a declared one-day window. */
205
+ function isExplicitLongWindow(window: WhamUsageWindow | null | undefined): boolean {
206
+ const seconds = window?.limit_window_seconds;
207
+ return typeof seconds === "number" && Number.isFinite(seconds) && seconds >= WEEKLY_WINDOW_MIN_SECONDS;
208
+ }
209
+
202
210
  function isExplicitMonthlyWindowMinutes(windowMinutes: unknown): boolean {
203
211
  const minutes = windowMinutes_(windowMinutes);
204
212
  return minutes !== undefined && minutes >= MONTHLY_WINDOW_MIN_MINUTES;
@@ -267,12 +275,17 @@ function snapshotHasCustom(quota: Omit<StoredAccountQuota, "updatedAt">): boolea
267
275
  function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
268
276
  return snapshotHasWeekly(quota) || snapshotHasMonthly(quota) || snapshotHasShort(quota) || snapshotHasCustom(quota);
269
277
  }
278
+ /**
279
+ * Publish parsed display quota and separately validated main-policy evidence after writer checks.
280
+ * A null policy observation retains only the matching main identity's previous evidence;
281
+ * transient replacement markers are consumed during merging and never enter stored snapshots.
282
+ */
270
283
  export function setAccountQuotaFromParsed(
271
284
  accountId: string,
272
285
  quota: Omit<StoredAccountQuota, "updatedAt"> | null,
273
286
  writerGeneration = captureConfigGeneration(),
274
287
  mainWriter?: MainQuotaWriter,
275
- policyQuota: Omit<StoredAccountQuota, "updatedAt"> | null = quota,
288
+ policyQuota: MainPolicyQuotaObservation | null = quota,
276
289
  historyEvidence?: QuotaObservationEvidence,
277
290
  ): void {
278
291
  quota = withoutRetiredCodexQuota(quota);
@@ -313,9 +326,13 @@ export function setAccountQuotaFromParsed(
313
326
  }
314
327
  }
315
328
 
316
- /** One partial-window merge contract for legacy quota and identity-bound policy evidence. */
329
+ /**
330
+ * Merge a partial observation into the legacy or identity-bound policy snapshot.
331
+ * Policy mode retains omitted blocking short usage unless this observation authorizes replacement;
332
+ * the returned snapshot contains quota fields only, without the transient replacement marker.
333
+ */
317
334
  function mergeAccountQuota(
318
- quota: Omit<StoredAccountQuota, "updatedAt">,
335
+ quota: MainPolicyQuotaObservation,
319
336
  existing: StoredAccountQuota | undefined,
320
337
  updatedAt: number,
321
338
  policyEvidence = false,
@@ -377,7 +394,7 @@ function mergeAccountQuota(
377
394
  }
378
395
  if (quota.shortResetAt !== undefined) next.shortResetAt = quota.shortResetAt;
379
396
  if (quota.shortWindowSeconds !== undefined) next.shortWindowSeconds = quota.shortWindowSeconds;
380
- } else {
397
+ } else if (!policyEvidence || quota.shortWindowAbsent !== true) {
381
398
  // Unknown usage is not a lower reading. Retain the entire known tuple: pairing
382
399
  // its percentage with new metadata would silently extend or shorten its reset.
383
400
  // An elapsed reset is the exception. It describes a window that has already rolled over,
@@ -793,13 +810,38 @@ function filterMainPolicyMonthlyQuota(
793
810
  return hasKnownQuotaValue(filtered) || filtered.resetCredits !== undefined ? filtered : null;
794
811
  }
795
812
 
796
- /** Ordinary main policy rejects an entire message containing any invalid numeric window. */
797
- export function parseMainPolicyUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuota, "updatedAt"> | null {
813
+ /**
814
+ * Parse ordinary main-policy usage, rejecting messages with invalid numeric window percentages.
815
+ * Mark a valid primary of at least 24h as replacement evidence only when both other windows
816
+ * are explicitly null or at least 24h. A null result supplies no usable policy observation.
817
+ */
818
+ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQuotaObservation | null {
798
819
  const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window];
799
820
  if (windows.some(window => isInvalidPolicyUsagePercent(window?.used_percent))) return null;
800
- return filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type));
821
+ const quota = filterMainPolicyMonthlyQuota(parseUsageQuota(data), isThirtyDayOnlyCodexPlan(data.plan_type));
822
+ const [primary, secondary, tertiary] = windows;
823
+ // WHAM explicitly reports absent windows as null; omissions cannot prove replacement.
824
+ // Policy trusts one complete snapshot only when every non-null window is >=24h AND
825
+ // carries a valid usage reading: a long window without used_percent leaves that
826
+ // window's usage unknown, and unknown usage must never release a block.
827
+ // Headers never supply this proof, and reset time alone still cannot release a block.
828
+ if (quota && normalizeUsagePercent(primary?.used_percent) !== undefined && isExplicitLongWindow(primary)
829
+ && (secondary === null || isMeasuredLongWindow(secondary))
830
+ && (tertiary === null || isMeasuredLongWindow(tertiary))) {
831
+ return { ...quota, shortWindowAbsent: true };
832
+ }
833
+ return quota;
801
834
  }
802
835
 
836
+ function isMeasuredLongWindow(window: WhamUsageWindow | null | undefined): boolean {
837
+ return isExplicitLongWindow(window) && normalizeUsagePercent(window?.used_percent) !== undefined;
838
+ }
839
+
840
+ /**
841
+ * Normalize WHAM windows into the display snapshot, preserving declared short-window shape.
842
+ * Finite percentages are clamped for compatibility; policy callers must validate raw readings
843
+ * separately. Return null when neither a quota value/window nor reset credits are available.
844
+ */
803
845
  export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuota, "updatedAt"> | null {
804
846
  const resetCredits = typeof data.rate_limit_reset_credits?.available_count === "number"
805
847
  ? data.rate_limit_reset_credits.available_count
@@ -4,6 +4,7 @@ import { homedir, tmpdir } from "node:os";
4
4
  import { delimiter, join } from "node:path";
5
5
  import { atomicWriteFile, getConfigDir } from "../config";
6
6
  import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation";
7
+ import { resolveCodexHomeDir } from "./home";
7
8
  import { redactSecretString, redactUserPath } from "../lib/redact";
8
9
 
9
10
  export type CodexRuntimeSource =
@@ -508,16 +509,13 @@ function pathCandidates(deps: ResolveCodexRuntimeDeps): string[] {
508
509
  function installedCodexCandidates(deps: ResolveCodexRuntimeDeps): string[] {
509
510
  const platform = deps.platform ?? process.platform;
510
511
  const env = deps.env ?? process.env;
511
- if (platform === "win32") {
512
- const localAppData = env.LOCALAPPDATA?.trim();
513
- if (!localAppData) return [];
514
- const root = join(localAppData, "OpenAI", "Codex", "bin");
515
- const readDir = deps.readdirSync ?? ((path: string) => readdirSync(path));
516
- const stat = deps.statSync ?? ((path: string) => statSync(path));
512
+ const readDir = deps.readdirSync ?? ((path: string) => readdirSync(path));
513
+ const stat = deps.statSync ?? ((path: string) => statSync(path));
514
+ /** Version directories directly under root, newest first; unreadable roots yield nothing. */
515
+ const versionDirectories = (root: string): string[] => {
517
516
  try {
518
- const names = readDir(root);
519
517
  const dirs: Array<{ name: string; directory: string; mtimeMs: number }> = [];
520
- for (const name of names) {
518
+ for (const name of readDir(root)) {
521
519
  const directory = join(root, name);
522
520
  try {
523
521
  const st = stat(directory);
@@ -528,18 +526,39 @@ function installedCodexCandidates(deps: ResolveCodexRuntimeDeps): string[] {
528
526
  }
529
527
  }
530
528
  dirs.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
531
- return dirs.map(entry => join(entry.directory, "codex.exe"));
529
+ return dirs.map(entry => entry.directory);
532
530
  } catch {
533
531
  return [];
534
532
  }
533
+ };
534
+ if (platform === "win32") {
535
+ const localAppData = env.LOCALAPPDATA?.trim();
536
+ if (!localAppData) return [];
537
+ return versionDirectories(join(localAppData, "OpenAI", "Codex", "bin"))
538
+ .map(directory => join(directory, "codex.exe"));
535
539
  }
536
540
  const home = env.HOME?.trim() || env.USERPROFILE?.trim() || homedir();
537
- return [
541
+ const posix = [
538
542
  join(home, ".codex", "packages", "standalone", "current", "bin", "codex"),
539
543
  join(home, ".local", "bin", "codex"),
540
544
  "/usr/local/bin/codex",
541
545
  "/opt/homebrew/bin/codex",
542
546
  ];
547
+ if (platform !== "linux") return posix;
548
+ // Windows Codex Desktop in WSL app-server mode ships its Linux binary under the
549
+ // effective Codex home as bin/wsl/<version-hash>/codex, and a Desktop update replaces
550
+ // that hash directory. The service PATH usually has no codex (issue 5635), so these
551
+ // rank after PATH and the ordinary locations and are re-enumerated on every resolve
552
+ // rather than trusted from a remembered hash.
553
+ let codexHome: string;
554
+ try {
555
+ codexHome = resolveCodexHomeDir({ env });
556
+ } catch {
557
+ return posix;
558
+ }
559
+ const desktopWsl = versionDirectories(join(codexHome, "bin", "wsl"))
560
+ .map(directory => join(directory, "codex"));
561
+ return [...posix, ...desktopWsl];
543
562
  }
544
563
 
545
564
  interface RankedCandidate {
@@ -735,6 +754,8 @@ function resolveCacheKey(deps: ResolveCodexRuntimeDeps): string | null {
735
754
  localAppData: env.LOCALAPPDATA?.trim() ?? "",
736
755
  homeDir: env.HOME?.trim() ?? "",
737
756
  userProfile: env.USERPROFILE?.trim() ?? "",
757
+ // Linux discovery enumerates <CODEX_HOME>/bin/wsl, so the home is part of the key.
758
+ codexHome: env.CODEX_HOME?.trim() ?? "",
738
759
  home: process.env.OPENCODEX_HOME ?? "",
739
760
  persisted: persistedRuntimeCacheStamp(deps),
740
761
  });
@@ -15,6 +15,7 @@ interface TargetCooldown {
15
15
 
16
16
  const DEFAULT_COOLDOWN_MS = 60_000;
17
17
  const MAX_COOLDOWN_MS = 10 * 60_000;
18
+ const MAX_SERVER_DELAY_MS = 24 * 60 * 60_000;
18
19
  /** Short cooldown for request-rate 429s (for example provider code 1302) that omit Retry-After. */
19
20
  export const COMBO_REQUEST_RATE_COOLDOWN_MS = 5_000;
20
21
 
@@ -115,6 +116,7 @@ function parseHttpDate(value: string, now: number): number | undefined {
115
116
  );
116
117
  }
117
118
 
119
+ /** Parse a Retry-After delay, optionally retaining an upstream delay up to one day. */
118
120
  export function parseRetryAfterMs(
119
121
  value: string | null | undefined,
120
122
  now = Date.now(),
@@ -122,11 +124,10 @@ export function parseRetryAfterMs(
122
124
  ): number | undefined {
123
125
  const text = value?.trim();
124
126
  if (!text) return undefined;
125
- // A local wait ceiling must not make an explicit upstream reset expire early.
126
- // Keep legacy bounded parsing for other callers. The opt-in stores a timestamp;
127
- // the combo picker still independently limits how long a live request waits.
127
+ // Keep legacy bounded parsing for other callers. Combo cooldowns preserve
128
+ // multi-hour upstream delays, but never quarantine a target beyond one day.
128
129
  const maximum = options?.preserveServerDelay === true
129
- ? Number.MAX_SAFE_INTEGER - Math.max(0, now)
130
+ ? MAX_SERVER_DELAY_MS
130
131
  : MAX_COOLDOWN_MS;
131
132
  if (/^\d+(?:\.\d+)?$/.test(text)) {
132
133
  const seconds = Number(text);
@@ -200,6 +201,7 @@ export function comboCooldownRetryAfterSeconds(comboId: string, now = Date.now()
200
201
  return String(Math.max(1, Math.ceil(remainingMs / 1000)));
201
202
  }
202
203
 
204
+ /** Record a combo target cooldown, preferring bounded upstream retry evidence. */
203
205
  export function coolComboTarget(
204
206
  comboId: string,
205
207
  target: Pick<OcxComboTarget, "provider" | "model">,
@@ -234,8 +236,7 @@ export function coolComboTarget(
234
236
  message: options?.message,
235
237
  }) ? COMBO_REQUEST_RATE_COOLDOWN_MS : DEFAULT_COOLDOWN_MS);
236
238
  targetCooldowns.set(cooldownMapKey(comboId, target), {
237
- // Only the locally chosen fallback is capped at ten minutes. An explicit
238
- // server lower bound (including one hour) remains authoritative.
239
+ // Local fallbacks are capped at ten minutes; explicit server delays at one day.
239
240
  cooldownUntil: now + (serverDelayMs ?? Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS)),
240
241
  });
241
242
  sweepExpiredOnWrite(now);
@@ -359,11 +359,19 @@ export function advanceComboAfterFailure(
359
359
  });
360
360
  }
361
361
  }
362
+ // #5691: under `cooldownWaitPolicy: "before-last-resort"` this final synchronous pick
363
+ // must not dispatch an emergency target while a normal one is merely cooling. Returning
364
+ // null hands the decision to `pickComboTargetWithWait`, which waits a normal target out
365
+ // inside the combo's wait budget or dispatches the last resort when none is reachable.
366
+ // Without the policy the eligible set is unchanged and the pick is exactly as before.
367
+ const defersLastResort = combo?.cooldownWaitPolicy === "before-last-resort"
368
+ && combo.targets.some(target => !target.lastResort);
362
369
  return pickComboTarget(config, pick.comboId, {
363
370
  exclude: pick.attempted,
364
371
  now: options.now,
365
372
  eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now)
366
- && (options.eligible?.(target) ?? true),
373
+ && (options.eligible?.(target) ?? true)
374
+ && (!defersLastResort || !target.lastResort),
367
375
  });
368
376
  }
369
377
 
@@ -382,23 +390,94 @@ export async function pickComboTargetWithWait(
382
390
  const now = options.now ?? Date.now();
383
391
  const excluded = new Set(options.exclude ?? []);
384
392
  const customEligible = options.eligible;
385
- const eligible = (target: Required<OcxComboTarget>): boolean =>
386
- !isComboTargetInCooldown(comboId, target, now)
393
+ const eligibleAt = (target: Required<OcxComboTarget>, at: number): boolean =>
394
+ !isComboTargetInCooldown(comboId, target, at)
387
395
  && (customEligible?.(target) ?? true);
388
- const pick = pickComboTarget(config, comboId, { exclude: excluded, eligible, now });
389
- if (pick || options.waitForCooldownMs <= 0 || options.abortSignal?.aborted) return pick;
396
+ const eligible = (target: Required<OcxComboTarget>): boolean => eligibleAt(target, now);
397
+ // Milliseconds already slept inside this call. `waitForCooldownMs` is documented as a cap
398
+ // per *selection attempt*, so a deferral wait and the ordinary wait below must share it —
399
+ // otherwise a 3s deferral followed by a 9s ordinary wait spends 12s against a 10s budget.
400
+ let spentWaitMs = 0;
401
+
402
+ // #5691: `cooldownWaitPolicy: "before-last-resort"` makes one extra attempt
403
+ // over the normal targets alone, so a *brief* cooldown on a preferred target
404
+ // waits rather than dispatching a target the operator marked emergency-only.
405
+ //
406
+ // It only ever defers. Every exit below falls through to the unchanged
407
+ // selection, which still sees the last-resort target — a policy that could
408
+ // withhold it when no normal target is reachable would turn a fallback into
409
+ // an outage, which is worse than the premature routing it prevents.
410
+ const policyCombo = getCombo(config, comboId);
411
+ const defersLastResort = policyCombo?.cooldownWaitPolicy === "before-last-resort"
412
+ && policyCombo.targets.some(target => !target.lastResort);
413
+ if (defersLastResort && !options.abortSignal?.aborted) {
414
+ const normalOnly = (target: Required<OcxComboTarget>): boolean =>
415
+ !target.lastResort && eligible(target);
416
+ const normalPick = pickComboTarget(config, comboId, {
417
+ exclude: excluded,
418
+ eligible: normalOnly,
419
+ now,
420
+ });
421
+ if (normalPick) return normalPick;
422
+
423
+ const waitable = policyCombo.targets.filter(target =>
424
+ !target.lastResort
425
+ && targetProviderIsUsable(config, target, now)
426
+ && !excluded.has(targetKey(target))
427
+ && isComboTargetInCooldown(comboId, target, now)
428
+ && (customEligible?.(target) ?? true),
429
+ );
430
+ const soonest = earliestComboCooldown(comboId, waitable, now);
431
+ const normalDelay = soonest === undefined ? undefined : soonest.expiry - now;
432
+ if (normalDelay !== undefined && normalDelay <= options.waitForCooldownMs) {
433
+ console.warn(
434
+ `[combo] ${comboId}: deferring last resort, waiting ${normalDelay}ms for ${targetKey(soonest!.target)}`,
435
+ );
436
+ try {
437
+ await (options.sleep ?? sleepWithAbort)(normalDelay, options.abortSignal);
438
+ } catch (error) {
439
+ if (options.abortSignal?.aborted) return null;
440
+ throw error;
441
+ }
442
+ spentWaitMs += normalDelay;
443
+ if (options.abortSignal?.aborted) return null;
444
+ // The combo can be deleted or renamed while this request sleeps.
445
+ if (!getCombo(config, comboId)) return null;
446
+ const waited = pickComboTarget(config, comboId, {
447
+ exclude: excluded,
448
+ now: now + normalDelay,
449
+ eligible: target => !target.lastResort
450
+ && !isComboTargetInCooldown(comboId, target, now + normalDelay)
451
+ && (customEligible?.(target) ?? true),
452
+ });
453
+ if (waited) return waited;
454
+ }
455
+ // No normal target is reachable. Fall through; the last resort is eligible.
456
+ }
457
+
458
+ // Both advance when the deferral above slept; they are identical to `now` and
459
+ // `options.waitForCooldownMs` when it did not, so the non-policy path is unchanged.
460
+ const clock = now + spentWaitMs;
461
+ const remainingWaitMs = options.waitForCooldownMs - spentWaitMs;
462
+
463
+ const pick = pickComboTarget(config, comboId, {
464
+ exclude: excluded,
465
+ eligible: target => eligibleAt(target, clock),
466
+ now: clock,
467
+ });
468
+ if (pick || remainingWaitMs <= 0 || options.abortSignal?.aborted) return pick;
390
469
  const combo = getCombo(config, comboId);
391
470
  if (!combo) throw new UnknownComboError(comboId);
392
471
  const waitingTargets = combo.targets.filter(target =>
393
- targetProviderIsUsable(config, target, now)
472
+ targetProviderIsUsable(config, target, clock)
394
473
  && !excluded.has(targetKey(target))
395
- && isComboTargetInCooldown(comboId, target, now)
474
+ && isComboTargetInCooldown(comboId, target, clock)
396
475
  && (customEligible?.(target) ?? true),
397
476
  );
398
- const earliest = earliestComboCooldown(comboId, waitingTargets, now);
477
+ const earliest = earliestComboCooldown(comboId, waitingTargets, clock);
399
478
  if (earliest === undefined) return null;
400
- const delay = earliest.expiry - now;
401
- if (delay > options.waitForCooldownMs) return null;
479
+ const delay = earliest.expiry - clock;
480
+ if (delay > remainingWaitMs) return null;
402
481
  // The expiry computation above is the single source of truth for the wait budget.
403
482
  // Its target preserves configured order for ties.
404
483
  const target = earliest.target;
@@ -416,10 +495,8 @@ export async function pickComboTargetWithWait(
416
495
  if (!getCombo(config, comboId)) return null;
417
496
  return pickComboTarget(config, comboId, {
418
497
  exclude: excluded,
419
- now: now + delay,
420
- eligible: targetCandidate =>
421
- !isComboTargetInCooldown(comboId, targetCandidate, now + delay)
422
- && (customEligible?.(targetCandidate) ?? true),
498
+ now: clock + delay,
499
+ eligible: targetCandidate => eligibleAt(targetCandidate, clock + delay),
423
500
  });
424
501
  }
425
502
 
@@ -1,6 +1,6 @@
1
1
  import { isCodexReasoningEffort } from "../reasoning-effort";
2
2
  import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models";
3
- import type { OcxComboConfig, OcxComboDefaultEffort, OcxComboDefaultEffortMode, OcxComboReasoningEffortMode, OcxComboStrategy, OcxComboTarget, OcxProviderConfig } from "../types";
3
+ import type { OcxComboConfig, OcxComboCooldownWaitPolicy, OcxComboDefaultEffort, OcxComboDefaultEffortMode, OcxComboReasoningEffortMode, OcxComboStrategy, OcxComboTarget, OcxProviderConfig } from "../types";
4
4
  import { COMBO_NAMESPACE, isValidComboId, targetKey } from "./identifiers";
5
5
 
6
6
  export const COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS = 0;
@@ -25,6 +25,8 @@ export interface NormalizedComboConfig {
25
25
  stickyLimit: number;
26
26
  cooldownMs?: number;
27
27
  waitForCooldownMs: number;
28
+ /** `before-last-resort` defers lastResort targets while a normal one can be waited out (#5691). */
29
+ cooldownWaitPolicy: OcxComboCooldownWaitPolicy | null;
28
30
  defaultEffort: OcxComboDefaultEffort | null;
29
31
  /** Client-precedence policy; `fallback` preserves legacy behavior. */
30
32
  defaultEffortMode: OcxComboDefaultEffortMode;
@@ -161,6 +163,13 @@ export function comboConfigIssues(
161
163
  || body.waitForCooldownMs > 600_000)) {
162
164
  issues.push({ path: ["waitForCooldownMs"], message: "waitForCooldownMs must be an integer from 0 to 600000" });
163
165
  }
166
+ if (body.cooldownWaitPolicy !== undefined && body.cooldownWaitPolicy !== null
167
+ && body.cooldownWaitPolicy !== "before-last-resort") {
168
+ issues.push({
169
+ path: ["cooldownWaitPolicy"],
170
+ message: 'cooldownWaitPolicy must be "before-last-resort" when set',
171
+ });
172
+ }
164
173
  if (body.defaultEffort !== undefined
165
174
  && body.defaultEffort !== null
166
175
  && (typeof body.defaultEffort !== "string" || !isCodexReasoningEffort(body.defaultEffort))) {
@@ -277,6 +286,12 @@ export function comboConfigIssues(
277
286
  message: `targets[${i}].weight must be an integer from 1 to 10000`,
278
287
  });
279
288
  }
289
+ if (target.lastResort !== undefined && typeof target.lastResort !== "boolean") {
290
+ issues.push({
291
+ path: ["targets", i, "lastResort"],
292
+ message: `targets[${i}].lastResort must be a boolean`,
293
+ });
294
+ }
280
295
 
281
296
  if (provider && model) {
282
297
  const key = targetKey({ provider, model });
@@ -318,6 +333,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig
318
333
  stickyLimit: raw.stickyLimit ?? 1,
319
334
  cooldownMs: raw.cooldownMs,
320
335
  waitForCooldownMs: raw.waitForCooldownMs ?? COMBO_DEFAULT_WAIT_FOR_COOLDOWN_MS,
336
+ cooldownWaitPolicy: raw.cooldownWaitPolicy === "before-last-resort" ? "before-last-resort" : null,
321
337
  defaultEffort,
322
338
  defaultEffortMode: raw.defaultEffortMode === "force" && defaultEffort !== null ? "force" : "fallback",
323
339
  reasoningEffortMode: raw.reasoningEffortMode === "adaptive" ? "adaptive" : "strict",
@@ -329,6 +345,7 @@ export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig
329
345
  provider: target.provider.trim(),
330
346
  model: target.model.trim(),
331
347
  weight: target.weight ?? 1,
348
+ lastResort: target.lastResort === true,
332
349
  })),
333
350
  };
334
351
  }
@@ -16,6 +16,7 @@ import { isValidProviderName } from "./provider-name";
16
16
  import { MODEL_ALIAS_PATTERN } from "../providers/default-aliases";
17
17
  import { MODEL_DISCOVERY_MAX_MODELS } from "../providers/model-discovery-limits";
18
18
  import { getProviderRegistryEntry, providerMatchesRegistryTransport, registryModelServiceTierCapabilityApplies } from "../providers/registry";
19
+ import { providerFastSwitchOff } from "../providers/fast-opt-in";
19
20
  import { isCodexReasoningEffort } from "../reasoning-effort";
20
21
  import { refreshConfigDerivedRegistries } from "./derived-registries";
21
22
  import { type OcxClaudeCodeConfig, type OcxConfig } from "../types";
@@ -835,6 +836,7 @@ export function inheritedFastWireConflictProviderNames(
835
836
  const conflicts: string[] = [];
836
837
  for (const [name, provider] of Object.entries(config.providers)) {
837
838
  if (provider.fastWire !== null || provider.supportsServiceTier === false) continue;
839
+ if (providerFastSwitchOff(name, provider)) continue;
838
840
  const registry = providerMatchesRegistryTransport(name, provider)
839
841
  ? getProviderRegistryEntry(name)
840
842
  : undefined;
@@ -60,6 +60,7 @@ import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
60
60
  import { modelAutoCompactTokenLimitsConfigError } from "../../providers/auto-compact-budget";
61
61
  import { hasFastWireCapabilityConflict } from "../../providers/fastwire";
62
62
  import { parseDesktopProfile } from "../../claude/desktop-profile";
63
+ import { isInterceptBindingId, isInterceptBindingRoute } from "../../claude/intercept/model-bindings";
63
64
  import { DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES, MAX_APP_OWNED_MEMORY_BUDGET_MB, MIN_APP_OWNED_MEMORY_BUDGET_MB } from "../../lib/app-owned-memory";
64
65
 
65
66
  export const configSchema = z.object({
@@ -148,7 +149,9 @@ export const configSchema = z.object({
148
149
  // Ultra Fast is opt-in for the same reason and degrades the same way: a malformed hand
149
150
  // edit turns the tier off rather than rejecting the config that carries it.
150
151
  ultraFastTier: z.boolean().optional().catch(false),
151
- codexMainAccountHardLock: z.boolean().optional().catch(false),
152
+ // Default-on policy (#5694): absence and malformed hand edits both mean "on", and only an
153
+ // explicit `false` written by the settings PUT opts out.
154
+ codexMainAccountHardLock: z.boolean().optional().catch(undefined),
152
155
  // Future versions remain opaque through passthrough-compatible whole-config saves.
153
156
  // Only version 1 grants deletion authority in the rebase path.
154
157
  configRebaseProvenance: z.unknown().optional(),
@@ -284,13 +287,29 @@ export const configSchema = z.object({
284
287
  if (!intercept || typeof intercept !== "object" || Array.isArray(intercept)) {
285
288
  ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept"], message: "intercept must be an object" });
286
289
  } else {
287
- const { enabled, port } = intercept as { enabled?: unknown; port?: unknown };
290
+ const { enabled, port, picker, modelMap } = intercept as { enabled?: unknown; port?: unknown; picker?: unknown; modelMap?: unknown };
288
291
  if (enabled !== undefined && typeof enabled !== "boolean") {
289
292
  ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "enabled"], message: "intercept.enabled must be a boolean" });
290
293
  }
294
+ if (picker !== undefined && typeof picker !== "boolean") {
295
+ ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "picker"], message: "intercept.picker must be a boolean" });
296
+ }
291
297
  if (port !== undefined && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) {
292
298
  ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "port"], message: "intercept.port must be an integer between 1 and 65535" });
293
299
  }
300
+ if (modelMap !== undefined) {
301
+ if (!modelMap || typeof modelMap !== "object" || Array.isArray(modelMap)) {
302
+ ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "modelMap"], message: "intercept.modelMap must be an object of picker id to route" });
303
+ } else {
304
+ for (const [id, route] of Object.entries(modelMap as Record<string, unknown>)) {
305
+ if (!isInterceptBindingId(id)) {
306
+ ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "modelMap", id], message: "intercept.modelMap keys must be claude- picker model ids" });
307
+ } else if (!isInterceptBindingRoute(route)) {
308
+ ctx.addIssue({ code: "custom", path: ["claudeCode", "intercept", "modelMap", id], message: "intercept.modelMap values must be non-empty routes without whitespace" });
309
+ }
310
+ }
311
+ }
312
+ }
294
313
  }
295
314
  }
296
315
  if (claude.desktopProfile !== undefined) {
@@ -292,6 +292,7 @@ export const providerConfigSchema = z.object({
292
292
  annotateEmptyToolOutputs: z.boolean().optional(),
293
293
  foldDeveloperRoleToSystem: z.boolean().optional(),
294
294
  fastWire: fastWireSchema.nullable().optional(),
295
+ fastEnabled: z.boolean().optional(),
295
296
  supportsServiceTier: z.boolean().optional(),
296
297
  modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
297
298
  modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(),
@@ -311,9 +312,11 @@ export const providerConfigSchema = z.object({
311
312
  upstreamHttpVersion: z.enum(UPSTREAM_HTTP_VERSION_VALUES)
312
313
  .nullish()
313
314
  .transform(value => value ?? undefined),
314
- // Opt-in upstream Responses WebSocket for OpenAI-compatible providers (e.g.
315
- // aggregators whose WebSocket ingress is measurably faster than SSE). The
316
- // canonical ChatGPT backend WS selection is independent of this flag.
315
+ // Opt-in upstream Responses WebSocket for OpenAI-compatible providers, honored only
316
+ // for the first-party api.openai.com/v1 upstream; other custom endpoints stay on
317
+ // bounded HTTP/SSE. On the canonical ChatGPT `openai` provider the same field selects
318
+ // the transport: omitted keeps the upstream WebSocket on eligible turns, explicit
319
+ // `false` sends streaming turns over HTTP/SSE, and provider management rejects `true`.
317
320
  upstreamWebsocket: z.boolean().optional(),
318
321
  directGeminiWireRenames: z.boolean().optional(),
319
322
  googleToolSchemaPolicy: z.enum(["compatible", "reject-lossy"]).optional(),