@bitkyc08/opencodex 2.76.0 → 2.77.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 (189) hide show
  1. package/gui/dist/assets/App-COdEeqSj.css +1 -0
  2. package/gui/dist/assets/App-DEpCJEP_.js +54 -0
  3. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
  4. package/gui/dist/assets/index-Cd7ittMv.css +1 -0
  5. package/gui/dist/assets/index-DwJKFJm5.js +94 -0
  6. package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
  7. package/gui/dist/index.html +4 -3
  8. package/package.json +1 -1
  9. package/src/adapters/antigravity-refusal.ts +28 -0
  10. package/src/adapters/cursor/protobuf-request.ts +6 -0
  11. package/src/adapters/cursor/request-builder.ts +3 -0
  12. package/src/adapters/cursor/structured-output.ts +13 -0
  13. package/src/adapters/cursor/types.ts +2 -0
  14. package/src/adapters/ollama-native.ts +29 -2
  15. package/src/adapters/openai-responses/passthrough.ts +133 -103
  16. package/src/adapters/openai-responses/request-strips.ts +10 -4
  17. package/src/adapters/zed.ts +26 -4
  18. package/src/bridge/response-json.ts +14 -14
  19. package/src/bridge/sse.ts +15 -5
  20. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  21. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  22. package/src/claude/agents-inject.ts +4 -43
  23. package/src/claude/gateway-cache.ts +16 -13
  24. package/src/claude/inbound-model-options.ts +9 -2
  25. package/src/claude/intercept/cli-catalog.ts +27 -16
  26. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  27. package/src/claude/intercept/picker-budget.ts +41 -0
  28. package/src/claude/outbound.ts +2 -0
  29. package/src/claude/subagent-force-status.ts +53 -0
  30. package/src/claude/subagent-model.ts +93 -0
  31. package/src/cli/account-api.ts +3 -0
  32. package/src/cli/account.ts +3 -1
  33. package/src/cli/agent.ts +10 -0
  34. package/src/cli/capabilities.ts +9 -0
  35. package/src/cli/chatgpt-command.ts +10 -7
  36. package/src/cli/claude.ts +22 -6
  37. package/src/cli/codex-shim-readiness.ts +89 -0
  38. package/src/cli/connect.ts +19 -0
  39. package/src/cli/dispatch.ts +3 -3
  40. package/src/cli/export-command.ts +20 -9
  41. package/src/cli/gui.ts +8 -3
  42. package/src/cli/help-catalog.ts +84 -0
  43. package/src/cli/help-models-context.ts +17 -0
  44. package/src/cli/help-navigation.ts +50 -0
  45. package/src/cli/help-recovery.ts +66 -0
  46. package/src/cli/help.ts +52 -8
  47. package/src/cli/models-runtime.ts +2 -1
  48. package/src/cli/opencode.ts +54 -10
  49. package/src/cli/provider.ts +3 -29
  50. package/src/cli/registry.ts +29 -5
  51. package/src/cli/root.ts +30 -9
  52. package/src/cli/runtime-api.ts +1 -1
  53. package/src/cli/tray-proxy.ts +3 -2
  54. package/src/clients/config-export/contracts.ts +100 -0
  55. package/src/clients/config-export/droid.ts +54 -2
  56. package/src/clients/config-export/kilo.ts +12 -2
  57. package/src/clients/config-export/model-metadata.ts +10 -4
  58. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  59. package/src/clients/config-export.ts +46 -66
  60. package/src/clients/omo-role-models.ts +17 -3
  61. package/src/codex/account-credit-use.ts +4 -2
  62. package/src/codex/account-runtime-state.ts +21 -0
  63. package/src/codex/account-store.ts +11 -47
  64. package/src/codex/account-usability.ts +11 -8
  65. package/src/codex/auth-api/account-list.ts +73 -63
  66. package/src/codex/auth-api/main-account-probe.ts +25 -12
  67. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  68. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  69. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  70. package/src/codex/auth-context.ts +30 -11
  71. package/src/codex/catalog/aggregation.ts +3 -0
  72. package/src/codex/catalog/model-hints.ts +2 -1
  73. package/src/codex/catalog/model-visibility.ts +6 -3
  74. package/src/codex/catalog/parsing.ts +2 -0
  75. package/src/codex/catalog/provider-models.ts +13 -4
  76. package/src/codex/catalog/retained-sync.ts +2 -1
  77. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  78. package/src/codex/convergence.ts +2 -1
  79. package/src/codex/main-account.ts +144 -11
  80. package/src/codex/quota-refresh-outcome.ts +17 -2
  81. package/src/codex/quota-types.ts +33 -1
  82. package/src/codex/quota.ts +56 -15
  83. package/src/codex/routing/cooldown-math.ts +10 -4
  84. package/src/codex/routing/selection.ts +11 -4
  85. package/src/codex/routing.ts +9 -9
  86. package/src/codex/shim-path-resolution.ts +133 -0
  87. package/src/codex/shim.ts +39 -39
  88. package/src/codex/subagent-model-fallback.ts +2 -1
  89. package/src/combos/failover.ts +82 -1
  90. package/src/combos/jev.ts +13 -3
  91. package/src/config/diagnostics.ts +4 -0
  92. package/src/config/load-degrade.ts +4 -0
  93. package/src/config/provider-validation.ts +3 -0
  94. package/src/config/schema/compaction-triggers.ts +8 -0
  95. package/src/config/schema/config-schema.ts +11 -0
  96. package/src/config/schema/leaf-validators.ts +5 -1
  97. package/src/config/subagent-models.ts +12 -0
  98. package/src/generated/compatibility-version.json +245 -153
  99. package/src/grok/session-identity.ts +33 -0
  100. package/src/integrations/catalog-refresh.ts +1 -1
  101. package/src/integrations/mutation-plan.ts +4 -2
  102. package/src/integrations/state.ts +68 -6
  103. package/src/integrations/writer.ts +18 -4
  104. package/src/lib/gui-pair-capability.ts +17 -0
  105. package/src/lib/provider-client-headers.ts +62 -0
  106. package/src/oauth/anthropic-continuity.ts +65 -0
  107. package/src/oauth/anthropic-identity.ts +75 -0
  108. package/src/oauth/anthropic-routing.ts +3 -3
  109. package/src/oauth/anthropic.ts +7 -1
  110. package/src/oauth/generic-account-failover.ts +21 -0
  111. package/src/oauth/health.ts +32 -5
  112. package/src/oauth/index.ts +36 -27
  113. package/src/oauth/local-token-detect.ts +7 -0
  114. package/src/oauth/refresh-policy.ts +2 -0
  115. package/src/oauth/store.ts +55 -6
  116. package/src/oauth/types.ts +9 -0
  117. package/src/protocols/encoders/messages.ts +4 -3
  118. package/src/providers/antigravity-effort-families.ts +75 -0
  119. package/src/providers/antigravity-models.ts +54 -16
  120. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  121. package/src/providers/derive.ts +2 -0
  122. package/src/providers/model-rename-fields.ts +3 -0
  123. package/src/providers/new-model-policy.ts +48 -10
  124. package/src/providers/registry/entries-extended.ts +3 -0
  125. package/src/providers/registry/model-ids.ts +2 -0
  126. package/src/providers/registry/model-seeds.ts +5 -0
  127. package/src/providers/registry/types.ts +4 -0
  128. package/src/responses/compaction.ts +19 -0
  129. package/src/router.ts +6 -0
  130. package/src/server/auth-cors.ts +9 -0
  131. package/src/server/chat-completions.ts +10 -0
  132. package/src/server/claude-messages.ts +18 -11
  133. package/src/server/droid-reasoning-default.ts +50 -0
  134. package/src/server/github-copilot-responses-repair.ts +3 -6
  135. package/src/server/grok-responses-control-frame.ts +2 -2
  136. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  137. package/src/server/gui-session.ts +33 -11
  138. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  139. package/src/server/index/serve-options.ts +2 -1
  140. package/src/server/management/agent-settings-routes.ts +6 -129
  141. package/src/server/management/config-routes.ts +1 -1
  142. package/src/server/management/integration-routes.ts +64 -6
  143. package/src/server/management/link-routes.ts +20 -13
  144. package/src/server/management/model-row-export-metadata.ts +150 -0
  145. package/src/server/management/model-rows.ts +148 -9
  146. package/src/server/management/route-registry.ts +4 -3
  147. package/src/server/management/shared.ts +19 -5
  148. package/src/server/management/subagent-model-routes.ts +214 -0
  149. package/src/server/relay.ts +14 -44
  150. package/src/server/request-log-generation-window.ts +38 -0
  151. package/src/server/request-log.ts +13 -0
  152. package/src/server/responses/adapter-dispatch.ts +100 -1
  153. package/src/server/responses/codex-auth-error.ts +10 -2
  154. package/src/server/responses/combo-stream-preflight.ts +29 -3
  155. package/src/server/responses/compaction-routing.ts +10 -1
  156. package/src/server/responses/core-auth.ts +57 -26
  157. package/src/server/responses/core-codex-account.ts +6 -3
  158. package/src/server/responses/core-combo-failure.ts +5 -1
  159. package/src/server/responses/core-combo-native.ts +7 -1
  160. package/src/server/responses/core-combo.ts +18 -1
  161. package/src/server/responses/core-normalize.ts +7 -0
  162. package/src/server/responses/core-options.ts +5 -0
  163. package/src/server/responses/fetch-helpers.ts +9 -0
  164. package/src/server/responses/passthrough-delivery.ts +1 -1
  165. package/src/server/responses/policy-fallback.ts +1 -1
  166. package/src/server/responses/request-prepare.ts +5 -0
  167. package/src/server/responses/request-spend.ts +30 -30
  168. package/src/server/responses-custom-tool-repair.ts +2 -2
  169. package/src/server/responses-hosted-image-display.ts +70 -9
  170. package/src/server/responses-terminal-repair.ts +15 -2
  171. package/src/server/sse-frame-buffer.ts +38 -36
  172. package/src/server/sse-payload-rewrite.ts +78 -49
  173. package/src/server/ws-bridge.ts +1 -11
  174. package/src/service/windows-taskxml.ts +1 -2
  175. package/src/service-manager-probe.ts +36 -2
  176. package/src/types/config.ts +4 -0
  177. package/src/types/provider.ts +18 -0
  178. package/src/update/npm-invocation.mjs +35 -3
  179. package/src/update/proxy-liveness-probe.mjs +38 -8
  180. package/src/usage/expected-prices.ts +9 -0
  181. package/src/usage/log.ts +7 -0
  182. package/src/usage/summary.ts +82 -1
  183. package/src/web-search/loop.ts +11 -8
  184. package/src/web-search/run-turn-loop.ts +10 -7
  185. package/gui/dist/assets/App-CddorHdA.js +0 -51
  186. package/gui/dist/assets/App-DMudtISW.css +0 -1
  187. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  188. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  189. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -1,3 +1,4 @@
1
+ import { codexAccountUsesCreditsAfterLimit } from "./account-credit-use";
1
2
  import { clearIdleWindowSteering, pickIdleWindowAccount } from "./routing/idle-window";
2
3
  import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
3
4
  import { codexQuotaHasFreshUsage } from "./quota-observation-freshness";
@@ -522,7 +523,7 @@ function pickAffinityPriorityFailback(
522
523
  || now - quota.updatedAt >= CODEX_PRIORITY_FAILBACK_REFRESH_MS
523
524
  || (quota.shortObservedAt !== undefined
524
525
  && now - quota.shortObservedAt >= CODEX_PRIORITY_FAILBACK_REFRESH_MS)) return false;
525
- const usage = computeCodexUsageScore(quota, plan, now);
526
+ const usage = computeCodexUsageScore(quota, plan, now, codexAccountUsesCreditsAfterLimit(config, id));
526
527
  const threshold = getEffectiveCodexAutoSwitchThreshold(config, id);
527
528
  return !isUnknownUsage(usage) && usage < 100 && (threshold <= 0 || usage < threshold);
528
529
  });
@@ -575,7 +576,7 @@ function previewReusableAffinityAccount(
575
576
  const usage = computeCodexUsageScore(
576
577
  getAccountQuota(entry.accountId),
577
578
  getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions),
578
- now,
579
+ now, codexAccountUsesCreditsAfterLimit(config, entry.accountId),
579
580
  );
580
581
  // Preview must agree with resolve: this is the second copy of the same rule, and the
581
582
  // suite asserts the two answer identically.
@@ -605,7 +606,7 @@ function resetFirstAffinityReplacement(
605
606
  ): string | null {
606
607
  const threshold = getEffectiveCodexAutoSwitchThreshold(config, entry.accountId);
607
608
  if (threshold <= 0) return null;
608
- const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now);
609
+ const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now, codexAccountUsesCreditsAfterLimit(config, entry.accountId));
609
610
  if (!mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) return null;
610
611
  const candidates = getEligiblePoolAccounts(config, entry.accountId, now, quotaScope, selectionOptions, true)
611
612
  // Headroom alone answers true for an UNMEASURED account, which is the right default for an
@@ -618,7 +619,7 @@ function resetFirstAffinityReplacement(
618
619
  return !isUnknownUsage(computeCodexUsageScore(
619
620
  getAccountQuota(id),
620
621
  getPoolAccountPlanForSelection(config, id, selectionOptions),
621
- now,
622
+ now, codexAccountUsesCreditsAfterLimit(config, id),
622
623
  ));
623
624
  });
624
625
  return pickResetFirstCodexAccount(config, candidates, now, selectionOptions);
@@ -664,10 +665,9 @@ function pickCacheSafeQuotaReplacement(
664
665
  ).filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
665
666
  const best = pickLowestUsageAmong(config, candidates, selectionOptions, now);
666
667
  if (best === null || best === boundAccountId) return null;
667
- const bestUsage = computeCodexUsageScore(
668
- getAccountQuota(best),
668
+ const bestUsage = computeCodexUsageScore(getAccountQuota(best),
669
669
  getPoolAccountPlanForSelection(config, best, selectionOptions),
670
- now,
670
+ now, codexAccountUsesCreditsAfterLimit(config, best),
671
671
  );
672
672
  return bestUsage < boundUsage ? best : null;
673
673
  }
@@ -698,7 +698,7 @@ function reevaluateAffinityQuota(
698
698
  ? computeCodexUsageScore(
699
699
  getAccountQuota(entry.accountId),
700
700
  getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions),
701
- now,
701
+ now, codexAccountUsesCreditsAfterLimit(config, entry.accountId),
702
702
  )
703
703
  : 0;
704
704
  // One bar, used for BOTH the rebind decision and the re-score interval. Keying the short
@@ -823,7 +823,7 @@ export function previewCodexAccountForRequest(
823
823
  const usage = computeCodexUsageScore(
824
824
  getAccountQuota(active),
825
825
  getPoolAccountPlanForSelection(config, active, selectionOptions),
826
- now,
826
+ now, codexAccountUsesCreditsAfterLimit(config, active),
827
827
  );
828
828
  if (!isUnknownUsage(usage) && usage >= threshold) {
829
829
  active = pickLowerUsageAccount(config, active, usage, now, quotaScope, selectionOptions);
@@ -0,0 +1,133 @@
1
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readSync, realpathSync, statSync } from "node:fs";
2
+ import { posix, win32 } from "node:path";
3
+ import { isWslRuntime, wslAutomountRoot } from "./home";
4
+ import { SHIM_MARKER } from "./shim-templates";
5
+
6
+ /**
7
+ * A PATH entry that reaches Windows through WSL drive interop
8
+ * (`<automount-root>/<drive>/...`; root defaults to /mnt, configurable via
9
+ * /etc/wsl.conf [automount] root).
10
+ */
11
+ export function isWindowsInteropDir(dir: string, automountRoot = "/mnt"): boolean {
12
+ const root = automountRoot.replace(/\/+$/, "");
13
+ const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+ return new RegExp(`^${escaped}/[a-z](/|$)`, "i").test(dir);
15
+ }
16
+
17
+ export type CodexPathScanDeps = {
18
+ pathValue?: string;
19
+ wsl?: boolean;
20
+ /** Treat PATH entries as POSIX paths (WSL context). Defaults to wsl || non-win32. */
21
+ posixPaths?: boolean;
22
+ automountRoot?: string;
23
+ exists?: (path: string) => boolean;
24
+ isShimFile?: (path: string) => boolean;
25
+ isDirectory?: (path: string) => boolean;
26
+ realpath?: (path: string) => string;
27
+ };
28
+
29
+ export type CodexPathCandidate = {
30
+ path: string;
31
+ isShim: boolean;
32
+ };
33
+
34
+ export function realIsDirectory(path: string): boolean {
35
+ try {
36
+ return lstatSync(path).isDirectory();
37
+ } catch {
38
+ return true; // unreadable -> treat as unusable
39
+ }
40
+ }
41
+
42
+ const SHIM_HEADER_MAX_BYTES = 16 * 1024;
43
+
44
+ function inspectShimFile(path: string): boolean | null {
45
+ let fd: number | undefined;
46
+ try {
47
+ // Follow npm launcher symlinks, but never read a known special file.
48
+ if (!statSync(path).isFile()) return null;
49
+ const flags = constants.O_RDONLY | (process.platform === "win32" ? 0 : constants.O_NONBLOCK);
50
+ fd = openSync(path, flags);
51
+ // The entry can change after stat; a replacement FIFO must not block open or read.
52
+ if (!fstatSync(fd).isFile()) return null;
53
+ const header = Buffer.allocUnsafe(SHIM_HEADER_MAX_BYTES);
54
+ let total = 0;
55
+ while (total < header.length) {
56
+ const count = readSync(fd, header, total, header.length - total, total);
57
+ if (count === 0) break;
58
+ total += count;
59
+ }
60
+ return header.subarray(0, total).toString("utf8").includes(SHIM_MARKER);
61
+ } catch {
62
+ // An unreadable command still shadows later PATH entries; do not claim they are active.
63
+ return false;
64
+ } finally {
65
+ if (fd !== undefined) closeSync(fd);
66
+ }
67
+ }
68
+
69
+ function commandNames(name: string): string[] {
70
+ if (process.platform !== "win32") return [name];
71
+ const exts = (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD;.PS1").split(";").filter(Boolean);
72
+ return [name, ...exts.flatMap(ext => [`${name}${ext.toLowerCase()}`, `${name}${ext.toUpperCase()}`])];
73
+ }
74
+
75
+ /** The shell-local bin directory that fnm creates for one interactive shell. */
76
+ export function isFnmMultishellPath(path: string, posixPaths: boolean): boolean {
77
+ const normalized = (posixPaths
78
+ ? posix.normalize(path)
79
+ : win32.normalize(path).replace(/\\/g, "/")).toLowerCase();
80
+ return /(?:^|\/)fnm_multishells?(?:\/|$)/.test(normalized);
81
+ }
82
+
83
+ /** Find the command the shell would actually use, including an OpenCodex shim. */
84
+ export function findFirstCodexOnPath(deps: CodexPathScanDeps = {}): CodexPathCandidate | null {
85
+ const exists = deps.exists ?? existsSync;
86
+ const shimFile = deps.isShimFile ?? inspectShimFile;
87
+ const isDir = deps.isDirectory ?? realIsDirectory;
88
+ const wsl = deps.wsl ?? (process.platform === "linux" && isWslRuntime());
89
+ const usePosix = deps.posixPaths ?? (wsl || process.platform !== "win32");
90
+ const joinPath = usePosix ? posix.join : win32.join;
91
+ const pathSep = usePosix ? ":" : win32.delimiter;
92
+ const automountRoot = deps.automountRoot ?? (wsl ? wslAutomountRoot() : "/mnt");
93
+ const interopNames = ["codex", "codex.exe", "codex.cmd", "codex.ps1"];
94
+
95
+ for (const dir of (deps.pathValue ?? process.env.PATH ?? "").split(pathSep).filter(Boolean)) {
96
+ if (wsl && isWindowsInteropDir(dir, automountRoot)) continue;
97
+ const names = isWindowsInteropDir(dir, automountRoot) ? interopNames : commandNames("codex");
98
+ for (const name of names) {
99
+ const path = joinPath(dir, name);
100
+ if (!exists(path) || isDir(path)) continue;
101
+ const isShim = shimFile(path);
102
+ if (isShim !== null) return { path, isShim };
103
+ }
104
+ }
105
+ return null;
106
+ }
107
+
108
+ /**
109
+ * Resolve a Codex command discovered through fnm's temporary PATH entry.
110
+ *
111
+ * A temporary path is safe to use only when its physical target is outside the
112
+ * temporary tree. Returning null is deliberate: callers must not wrap the
113
+ * shell-local path or silently choose a lower-priority Codex installation.
114
+ */
115
+ export function resolveStableFnmCodexPath(
116
+ path: string,
117
+ posixPaths: boolean,
118
+ realpath: ((path: string) => string) | undefined = realpathSync.native,
119
+ ): string | null {
120
+ if (!isFnmMultishellPath(path, posixPaths)) return path;
121
+ try {
122
+ // fnm normally links the temporary directory to the durable installation,
123
+ // while npm may link the command file again into its package internals.
124
+ // Resolve only the directory so the command basename remains the stable
125
+ // installation entry that the installer is allowed to replace.
126
+ const pathTools = posixPaths ? posix : win32;
127
+ const resolvedParent = realpath(pathTools.dirname(path));
128
+ if (!resolvedParent || isFnmMultishellPath(resolvedParent, posixPaths) || !pathTools.isAbsolute(resolvedParent)) return null;
129
+ return pathTools.join(resolvedParent, pathTools.basename(path));
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
package/src/codex/shim.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  unlinkSync,
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
- import { basename, delimiter, dirname, extname, join, posix } from "node:path";
11
+ import { basename, delimiter, dirname, extname, join, posix, win32 } from "node:path";
12
12
  import { durableBunRuntime } from "../lib/bun-runtime";
13
13
  import type { BunRuntimeSource } from "../lib/bun-runtime";
14
14
  import { serviceApiTokenFilePath } from "../lib/service-secrets";
@@ -53,12 +53,20 @@ import {
53
53
  type UnixShimProbeResult,
54
54
  } from "./shim-probe";
55
55
  import { tryAcquireShimRestoreLock } from "./shim-restore-lock";
56
+ import {
57
+ isWindowsInteropDir,
58
+ realIsDirectory,
59
+ resolveStableFnmCodexPath,
60
+ type CodexPathScanDeps,
61
+ } from "./shim-path-resolution";
56
62
 
57
63
  export { buildUnixCodexShim, buildWindowsCodexShim, buildWindowsPowerShellCodexShim } from "./shim-templates";
58
64
  export { isVersionManagerOwnedCodexPath } from "./shim-fingerprint";
59
65
  export { CODEX_SHIM_STATE_MAX_BYTES } from "./shim-state-file";
60
66
  export { setCodexShimProbeHookForTests, setCodexShimProbeShellForTests, setCodexShimProbeObservationMsForTests } from "./shim-probe";
61
67
  export type { CodexShimBackingForCommand } from "./shim-inspect";
68
+ export type { CodexPathScanDeps } from "./shim-path-resolution";
69
+ export { isWindowsInteropDir } from "./shim-path-resolution";
62
70
  export { isLocalAbsoluteInspectionPath, inspectCodexShimBackingForCommand } from "./shim-inspect";
63
71
 
64
72
  export const CODEX_SHIM_REPLACEMENT_STABLE_MS = 100;
@@ -75,6 +83,17 @@ interface InstallCodexShimInternalOptions {
75
83
  beforeGuardedRefresh?: (wrapperPath: string, index: number) => void;
76
84
  }
77
85
 
86
+ function discoveredCodexPath(path: string, posixPaths: boolean, realpath?: (path: string) => string): string | null {
87
+ const resolved = resolveStableFnmCodexPath(path, posixPaths, realpath);
88
+ if (resolved !== null) return resolved;
89
+ lastShimDiscoveryError = truncateRetainedUtf8(
90
+ `Found codex at ${path} inside fnm's temporary multishell path, but it did not resolve to a durable Node installation. `
91
+ + "Refusing to install a shim; retry after fnm selects a stable installation.",
92
+ MAX_DIAGNOSTIC_VALUE_BYTES,
93
+ );
94
+ return null;
95
+ }
96
+
78
97
  export type CodexShimAutoRestoreResult =
79
98
  | { status: "not-installed" | "healthy" | "disabled" }
80
99
  | { status: "ineligible" | "deferred"; message?: string }
@@ -115,36 +134,6 @@ function isHealthyShim(path: string, platform: NodeJS.Platform): boolean {
115
134
  }
116
135
  }
117
136
 
118
- /**
119
- * A PATH entry that reaches Windows through WSL drive interop
120
- * (`<automount-root>/<drive>/...`; root defaults to /mnt, configurable via
121
- * /etc/wsl.conf [automount] root).
122
- */
123
- export function isWindowsInteropDir(dir: string, automountRoot = "/mnt"): boolean {
124
- const root = automountRoot.replace(/\/+$/, "");
125
- const escaped = root.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
126
- return new RegExp(`^${escaped}/[a-z](/|$)`, "i").test(dir);
127
- }
128
-
129
- export type CodexPathScanDeps = {
130
- pathValue?: string;
131
- wsl?: boolean;
132
- /** Treat PATH entries as POSIX paths (WSL context). Defaults to wsl || non-win32. */
133
- posixPaths?: boolean;
134
- automountRoot?: string;
135
- exists?: (path: string) => boolean;
136
- isShimFile?: (path: string) => boolean;
137
- isDirectory?: (path: string) => boolean;
138
- };
139
-
140
- function realIsDirectory(path: string): boolean {
141
- try {
142
- return lstatSync(path).isDirectory();
143
- } catch {
144
- return true; // unreadable -> treat as unusable
145
- }
146
- }
147
-
148
137
  export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
149
138
  lastShimDiscoveryError = null;
150
139
  const exists = deps.exists ?? existsSync;
@@ -152,8 +141,10 @@ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
152
141
  const isDir = deps.isDirectory ?? realIsDirectory;
153
142
  const wsl = deps.wsl ?? (process.platform === "linux" && isWslRuntime());
154
143
  const usePosix = deps.posixPaths ?? (wsl || process.platform !== "win32");
155
- const joinPath = usePosix ? posix.join : join;
156
- const pathSep = usePosix ? ":" : delimiter;
144
+ // The injected path flavour governs join and separator alike; host-native `join` and
145
+ // `delimiter` made a Windows-flavoured scan split `C:\...` on a Linux host.
146
+ const joinPath = usePosix ? posix.join : win32.join;
147
+ const pathSep = usePosix ? ":" : win32.delimiter;
157
148
  const automountRoot = deps.automountRoot ?? (wsl ? wslAutomountRoot() : "/mnt");
158
149
  // Windows npm prefixes ship codex.exe/codex.cmd next to the extensionless sh launcher.
159
150
  const interopNames = ["codex", "codex.exe", "codex.cmd", "codex.ps1"];
@@ -176,7 +167,11 @@ export function findCodexOnPath(deps: CodexPathScanDeps = {}): string | null {
176
167
  for (const name of names) {
177
168
  const path = joinPath(dir, name);
178
169
  if (!exists(path) || shimFile(path)) continue;
179
- if (!isDir(path)) return path;
170
+ if (!isDir(path)) {
171
+ const resolved = discoveredCodexPath(path, usePosix, deps.realpath);
172
+ if (resolved !== null) return resolved;
173
+ return null;
174
+ }
180
175
  }
181
176
  }
182
177
 
@@ -195,11 +190,13 @@ function findWindowsCodexTargets(): ShimFileState[] | null {
195
190
  lastShimDiscoveryError = null;
196
191
  for (const dir of (process.env.PATH ?? "").split(delimiter).filter(Boolean)) {
197
192
  const exe = join(dir, "codex.exe");
198
- if (existsSync(exe) && !isShim(exe)) {
193
+ const stableExe = existsSync(exe) && !isShim(exe) ? discoveredCodexPath(exe, false) : exe;
194
+ if (stableExe === null) return null;
195
+ if (existsSync(stableExe) && !isShim(stableExe)) {
199
196
  try {
200
- if (!lstatSync(exe).isDirectory()) {
197
+ if (!lstatSync(stableExe).isDirectory()) {
201
198
  lastShimDiscoveryError = truncateRetainedUtf8(
202
- `Found codex.exe at ${exe}. Refusing to rename a real .exe because exact codex.exe invocations would break; ` +
199
+ `Found codex.exe at ${stableExe}. Refusing to rename a real .exe because exact codex.exe invocations would break; ` +
203
200
  "install a codex.cmd/codex.ps1 launcher or use `ocx service install` for autostart.",
204
201
  MAX_DIAGNOSTIC_VALUE_BYTES,
205
202
  );
@@ -216,9 +213,12 @@ function findWindowsCodexTargets(): ShimFileState[] | null {
216
213
  const targets: ShimFileState[] = [];
217
214
  for (const path of [cmd, ps1, gitBashLauncher]) {
218
215
  if (!existsSync(path) || isShim(path)) continue;
216
+ const stablePath = discoveredCodexPath(path, false);
217
+ if (stablePath === null) return null;
218
+ if (!existsSync(stablePath) || isShim(stablePath)) continue;
219
219
  try {
220
- if (!lstatSync(path).isDirectory()) {
221
- targets.push({ wrapperPath: path, originalPath: path, backupPath: backupPathFor(path) });
220
+ if (!lstatSync(stablePath).isDirectory()) {
221
+ targets.push({ wrapperPath: stablePath, originalPath: stablePath, backupPath: backupPathFor(stablePath) });
222
222
  }
223
223
  } catch { /* keep scanning */ }
224
224
  }
@@ -1,3 +1,4 @@
1
+ import { codexAccountUsesCreditsAfterLimit } from "./account-credit-use";
1
2
  /**
2
3
  * Quota-aware subagent model fallback (issue #374).
3
4
  *
@@ -233,7 +234,7 @@ export function isNativeModelQuotaExhausted(
233
234
  // Subagent fallback reads the same score, so a stale terminal reading would push
234
235
  // subagents off a native model whose window has already reset. Thread the caller's clock
235
236
  // rather than letting the scorer read wall time - the two would silently diverge.
236
- const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId), now);
237
+ const usage = computeCodexUsageScore(quota, getPoolAccountPlan(config, resolvedAccountId), now, codexAccountUsesCreditsAfterLimit(config, resolvedAccountId));
237
238
  if (usage >= CODEX_UNKNOWN_USAGE_SCORE) return false;
238
239
  return usage >= quotaThreshold(config, resolvedAccountId);
239
240
  }
@@ -670,10 +670,84 @@ function isDefiniteContextOverflow(status: number, message: string): boolean {
670
670
  return false;
671
671
  }
672
672
 
673
+ const CODEX_ACCOUNT_MODEL_REFUSAL = /^The '[^']{1,256}' model is not supported when using Codex with a ChatGPT account\.$/;
674
+
675
+ export type CodexAccountModelRefusal = "other" | "refusal" | "ambiguous";
676
+
677
+ /** Inspect only the original root and its own response record, before carrier selection. */
678
+ export function hasConflictingCodexModelRefusalEnvelopes(status: number, payload: unknown): boolean {
679
+ if (status !== 400 || !payload || typeof payload !== "object" || Array.isArray(payload)) return false;
680
+ const response = Object.hasOwn(payload, "response") ? (payload as Record<string, unknown>).response : undefined;
681
+ const nested = response && typeof response === "object" && !Array.isArray(response) ? response : undefined;
682
+ return (Object.hasOwn(payload, "detail") || !!nested && Object.hasOwn(nested, "detail"))
683
+ && (Object.hasOwn(payload, "error") || !!nested && Object.hasOwn(nested, "error"));
684
+ }
685
+
686
+ /** Inspect an already parsed, bounded error frame without copying or walking its body. */
687
+ export function codexAccountModelRefusalPayload(status: number, payload: unknown): CodexAccountModelRefusal {
688
+ if (status !== 400 || !payload || typeof payload !== "object" || Array.isArray(payload)) return "other";
689
+ if (Object.hasOwn(payload, "detail") && Object.hasOwn(payload, "error")) return "ambiguous";
690
+ const { detail, error } = payload as { detail?: unknown; error?: unknown };
691
+ const field = typeof detail === "string" ? detail
692
+ : error && typeof error === "object" && !Array.isArray(error)
693
+ && typeof (error as Record<string, unknown>).message === "string"
694
+ ? (error as { message: string }).message : undefined;
695
+ return field !== undefined && CODEX_ACCOUNT_MODEL_REFUSAL.test(field) ? "refusal" : "other";
696
+ }
697
+
698
+ /** One complete, bounded HTTP envelope; accept only a single exact status prefix. */
699
+ function parseCodexRefusalEnvelope(status: number, message: string): { text: string; payload: unknown } | undefined {
700
+ if (status !== 400 || message.length > 16_384) return undefined;
701
+ let text = message.trim();
702
+ if (text.startsWith("Provider error 400: ")) text = text.slice("Provider error 400: ".length);
703
+ let payload: unknown;
704
+ try { payload = JSON.parse(text); } catch { /* Bare refusal text is also a supported carrier. */ }
705
+ return { text, payload };
706
+ }
707
+
708
+ /** Only allowlisted hard stops cross the same HTTP boundary as positive refusal evidence. */
709
+ export function codexAccountModelRefusalHardStopCode(status: number, message: string): string | undefined {
710
+ const payload = parseCodexRefusalEnvelope(status, message)?.payload;
711
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
712
+ const root = payload as Record<string, unknown>;
713
+ const response = Object.hasOwn(root, "response") ? root.response : undefined;
714
+ const records = response && typeof response === "object" && !Array.isArray(response)
715
+ ? [root, response as Record<string, unknown>] : [root];
716
+ for (const record of records) {
717
+ const error = record.error && typeof record.error === "object" && !Array.isArray(record.error)
718
+ ? record.error as Record<string, unknown> : undefined;
719
+ // Diagnostic type is never code evidence, even when a code is absent or malformed.
720
+ for (const code of [error?.code, record.code]) {
721
+ if (typeof code !== "string") continue;
722
+ if (isNonReplayableUpstreamCode(code) || isCyberPolicyCode(code)) return code;
723
+ if (normalizedFailureCode(code) === "origin_rejected") return "origin_rejected";
724
+ }
725
+ }
726
+ return undefined;
727
+ }
728
+
729
+ /** A bounded fallback signal, never credential or account-entitlement evidence. */
730
+ export function codexAccountModelRefusal(
731
+ status: number, message: string, options?: { allowNestedResponse?: boolean },
732
+ ): CodexAccountModelRefusal {
733
+ const envelope = parseCodexRefusalEnvelope(status, message);
734
+ if (!envelope) return "other";
735
+ if (CODEX_ACCOUNT_MODEL_REFUSAL.test(envelope.text)) return "refusal";
736
+ const payload = envelope.payload;
737
+ if (hasConflictingCodexModelRefusalEnvelopes(status, payload)) return "ambiguous";
738
+ // A present root carrier stays authoritative, even when malformed or nonmatching.
739
+ if (options?.allowNestedResponse !== false && payload && typeof payload === "object" && !Array.isArray(payload)
740
+ && !Object.hasOwn(payload, "detail") && !Object.hasOwn(payload, "error")
741
+ && Object.hasOwn(payload, "response")) {
742
+ return codexAccountModelRefusalPayload(status, (payload as Record<string, unknown>).response);
743
+ }
744
+ return codexAccountModelRefusalPayload(status, payload);
745
+ }
746
+
673
747
  export function comboFailureDecision(
674
748
  status: number,
675
749
  message: string,
676
- options?: { code?: string | null },
750
+ options?: { code?: string | null; codexModelRefusal?: CodexAccountModelRefusal },
677
751
  ): ComboFailureDecision {
678
752
  if (status === 499) return "stop";
679
753
  if (message.toLowerCase().includes("origin_rejected")) return "stop";
@@ -688,6 +762,12 @@ export function comboFailureDecision(
688
762
  // Cyber policy is a hard non-retryable refusal — honor structured code even when
689
763
  // classificationText was truncated before the JSON code field.
690
764
  if (isCyberPolicyCode(options?.code)) return "stop";
765
+ // HTTP consumers retain hard codes before truncation; SSE metadata owns its selected carrier.
766
+ if (options?.codexModelRefusal === undefined && codexAccountModelRefusalHardStopCode(status, message)) return "stop";
767
+ const modelRefusal = status === 400
768
+ ? options?.codexModelRefusal ?? codexAccountModelRefusal(status, message) : "other";
769
+ // Competing envelopes cannot grant a hop through an earlier structured-code rule either.
770
+ if (modelRefusal === "ambiguous") return "stop";
691
771
  // HTTP 410 is normally terminal. A model-specific lifecycle verdict is target-local,
692
772
  // however: another provider/model in the declared combo can still serve the request.
693
773
  // Require structured lifecycle code or explicit model+lifecycle prose so unrelated
@@ -738,6 +818,7 @@ export function comboFailureDecision(
738
818
  if (["model_not_found", "model_unavailable", "unsupported_model"].includes(failureCode)) {
739
819
  return "hop";
740
820
  }
821
+ if (modelRefusal === "refusal") return "hop";
741
822
  // `free_rate_limited` no longer routes through `isProviderScopedQuotaCap` (it is a
742
823
  // per-request cap, not provider-wide evidence), so keep its hop verdict explicit here.
743
824
  if (failureCode === "free_rate_limited") return "hop";
package/src/combos/jev.ts CHANGED
@@ -127,6 +127,8 @@ export interface ResolveJevDecisionOptions {
127
127
  * any other id names a configured `jev-decision` row (for example a self-hosted Ollama `tev1`).
128
128
  */
129
129
  decisionProvider?: string;
130
+ /** Request-local authorization of the concrete decision destination, before credential access. */
131
+ isDestinationAllowed?: (providerName: string, modelId: string) => boolean;
130
132
  /** Decision deadline; values outside 1000..120000 ms keep the four-second default. */
131
133
  timeoutMs?: number;
132
134
  signal?: AbortSignal;
@@ -674,14 +676,20 @@ function selfHostedApiKey(name: string, apiKey: string | undefined): string | un
674
676
  * custom destination. Any other id must be an enabled `jev-decision` row whose baseUrl is a
675
677
  * `/systemone` endpoint and which names its own model; only its own key may accompany it, so no
676
678
  * TypeSafe credential can reach a self-hosted service. `undefined` means no usable decision
677
- * service (reported through the existing `missing_key` gate).
679
+ * service (reported through the existing `missing_key` gate); `null` means the request's
680
+ * destination scope refused it before any credential access.
678
681
  */
679
- function jevDecisionEndpoint(config: OcxConfig, decisionProvider: string): JevDecisionEndpoint | undefined {
682
+ function jevDecisionEndpoint(
683
+ config: OcxConfig,
684
+ decisionProvider: string,
685
+ isDestinationAllowed?: ResolveJevDecisionOptions["isDestinationAllowed"],
686
+ ): JevDecisionEndpoint | undefined | null {
680
687
  const configured = Object.hasOwn(config.providers, decisionProvider)
681
688
  ? config.providers[decisionProvider]
682
689
  : undefined;
683
690
  if (configured?.disabled === true) return undefined;
684
691
  if (decisionProvider === JEV_PROVIDER_ID) {
692
+ if (isDestinationAllowed?.(JEV_PROVIDER_ID, JEV_MODEL) === false) return null;
685
693
  const configuredOwnsJev = configured
686
694
  && providerMatchesRegistryTransport(JEV_PROVIDER_ID, configured);
687
695
  const apiKey = (
@@ -704,6 +712,7 @@ function jevDecisionEndpoint(config: OcxConfig, decisionProvider: string): JevDe
704
712
  // `jev-latest` is TypeSafe's model name; a self-hosted host must name its own.
705
713
  const model = configured.defaultModel?.trim() || configured.models?.[0]?.trim();
706
714
  if (!model) return undefined;
715
+ if (isDestinationAllowed?.(decisionProvider, model) === false) return null;
707
716
  const apiKey = selfHostedApiKey(decisionProvider, configured.apiKey);
708
717
  if (apiKey === null) return undefined;
709
718
  return {
@@ -733,7 +742,8 @@ export async function resolveJevDecision(options: ResolveJevDecisionOptions): Pr
733
742
  if (options.candidates.length === 0) return failed("no_choices");
734
743
  if (!candidatesFitRequestBounds(options.candidates)) return failed("invalid");
735
744
 
736
- const endpoint = jevDecisionEndpoint(options.config, options.decisionProvider ?? JEV_PROVIDER_ID);
745
+ const endpoint = jevDecisionEndpoint(options.config, options.decisionProvider ?? JEV_PROVIDER_ID, options.isDestinationAllowed);
746
+ if (endpoint === null) return failed("invalid");
737
747
  if (!endpoint) return failed("missing_key");
738
748
 
739
749
  let requestBody: string;
@@ -1,3 +1,4 @@
1
+ import { isSubagentModelEntry, rawSubagentModelForce } from "./subagent-models";
1
2
  import { createHash } from "node:crypto";
2
3
  import { lstatSync, readFileSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -100,9 +101,11 @@ function validFileConfigDiagnostics(config: OcxConfig, rawParsed: unknown): Conf
100
101
  // the entire config, which would hide unrelated providers/accounts. The next
101
102
  // ordinary save persists the normalized absence.
102
103
  const syncDisabledReason = nativeSubagentSyncDisabledReason(config, rawParsed);
104
+ const rawForce = rawSubagentModelForce(rawParsed);
103
105
  const rawEffort = rawClaudeSubagentEffort(rawParsed);
104
106
  const normalized = normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, rawParsed), rawParsed);
105
107
  const warnings = configPlaceholderWarnings(normalized);
108
+ if (rawForce !== undefined && !isSubagentModelEntry(rawForce)) warnings.push("claudeCode.subagentModelForce ignored: expected a safe roster-style model id");
106
109
  if (normalized.chatgptDesktop?.appServerShim === true && process.platform !== "darwin") {
107
110
  warnings.push("chatgptDesktop.appServerShim is experimental and macOS only; ignored on this platform");
108
111
  }
@@ -652,6 +655,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
652
655
  const boundaryError = blockedModelRedirectsError(value)
653
656
  ?? compactionRecoveryConfigError(value) ?? configReasoningPinsConfigError(value)
654
657
  ?? blankHostnameError(value)
658
+ ?? (rawSubagentModelForce(value) !== undefined && !isSubagentModelEntry(rawSubagentModelForce(value)) ? "schema_invalid: claudeCode.subagentModelForce: expected a safe roster-style model id" : null)
655
659
  ?? claudeSubagentEffortError(value)
656
660
  ?? appOwnedMemoryBudgetError(value)
657
661
  ?? upstreamHostCircuitThresholdError(value)
@@ -1,3 +1,4 @@
1
+ import { isSubagentModelEntry, rawSubagentModelForce } from "./subagent-models";
1
2
  import { chmodSync, existsSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { compactionRecoveryConfigError } from "./schema/compaction-recovery";
@@ -592,6 +593,7 @@ export function normalizePersistedClaudeCode(claudeCode: unknown): OcxConfig["cl
592
593
  return claudeCode as OcxConfig["claudeCode"];
593
594
  }
594
595
  const normalized = { ...claudeCode } as Record<string, unknown>;
596
+ if (Object.hasOwn(normalized, "subagentModelForce") && !isSubagentModelEntry(normalized.subagentModelForce)) delete normalized.subagentModelForce;
595
597
  // A malformed hand edit must not arm CLI interception or discard the whole config.
596
598
  if (Object.hasOwn(normalized, "cliFirstParty") && typeof normalized.cliFirstParty !== "boolean") {
597
599
  delete normalized.cliFirstParty;
@@ -634,6 +636,8 @@ export function normalizeClaudeSubagentEffort(config: OcxConfig, _rawParsed: unk
634
636
  }
635
637
 
636
638
  export function warnDegradedClaudeSubagentEffort(rawParsed: unknown): void {
639
+ const force = rawSubagentModelForce(rawParsed);
640
+ if (force !== undefined && !isSubagentModelEntry(force)) console.warn("⚠️ config.json claudeCode.subagentModelForce is invalid — ignoring it. Other settings were preserved.");
637
641
  const rawEffort = rawClaudeSubagentEffort(rawParsed);
638
642
  if (rawEffort !== undefined && !isClaudeSubagentEffort(rawEffort)) {
639
643
  console.warn(`⚠️ config.json claudeCode.subagentEffort is invalid (expected ${CLAUDE_SUBAGENT_EFFORTS.join(", ")}) — ignoring it. Other settings were preserved.`);
@@ -6,6 +6,7 @@ import {
6
6
  } from "../providers/model-discovery-limits";
7
7
  import { isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort";
8
8
  import { encodeRoutedModelId } from "../providers/slug-codec";
9
+ import { providerForwardClientHeadersConfigError } from "../lib/provider-client-headers";
9
10
  import {
10
11
  isWirePinnedModel,
11
12
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
@@ -125,6 +126,8 @@ export function providerHeadersConfigError(headers: unknown): string | null {
125
126
  return null;
126
127
  }
127
128
 
129
+ export { providerForwardClientHeadersConfigError };
130
+
128
131
  /** Keep the configured API-key header style scoped to Anthropic-compatible key auth. */
129
132
  export function apiKeyTransportConfigError(
130
133
  provider: Pick<OcxProviderConfig, "adapter" | "authMode" | "apiKeyTransport">,
@@ -9,3 +9,11 @@
9
9
  * with "Cannot access 'runtimeRoleSchema' before initialization".
10
10
  */
11
11
  export const COMPACTION_TRIGGERS = ["manual", "auto"] as const;
12
+
13
+ /** Exact selectors or a provider-qualified trailing wildcard; never a global wildcard. */
14
+ export function validCompactionSourceModels(value: unknown): value is string[] {
15
+ return Array.isArray(value) && value.length > 0 && new Set(value).size === value.length
16
+ && value.every(entry => typeof entry === "string" && entry.length > 0
17
+ && entry === entry.trim() && !/\s/.test(entry)
18
+ && (!entry.includes("*") || /^[^/*]+\/\*$/.test(entry)));
19
+ }
@@ -45,6 +45,7 @@ import {
45
45
  positiveIntegerRecordConfigError,
46
46
  providerBaseUrlConfigError,
47
47
  providerHeadersConfigError,
48
+ providerForwardClientHeadersConfigError,
48
49
  reasoningSummaryDeliveryRecordConfigError,
49
50
  } from "../provider-validation";
50
51
  import {
@@ -506,6 +507,16 @@ export const configSchema = z.object({
506
507
  message: headersError,
507
508
  });
508
509
  }
510
+ const forwardClientHeadersError = providerForwardClientHeadersConfigError(
511
+ (provider as { forwardClientHeaders?: unknown }).forwardClientHeaders,
512
+ );
513
+ if (forwardClientHeadersError) {
514
+ ctx.addIssue({
515
+ code: "custom",
516
+ path: ["providers", redactSecretString(name), "forwardClientHeaders"],
517
+ message: forwardClientHeadersError,
518
+ });
519
+ }
509
520
  const modelCostsError = providerModelCostsConfigError((provider as { modelCosts?: unknown }).modelCosts);
510
521
  if (modelCostsError) {
511
522
  ctx.addIssue({
@@ -41,7 +41,7 @@ import {
41
41
  isHostedToolUnsupportedForModel,
42
42
  } from "../../responses/hosted-tool-policy";
43
43
  import { getConfigDir } from "../paths";
44
- import { COMPACTION_TRIGGERS } from "./compaction-triggers";
44
+ import { COMPACTION_TRIGGERS, validCompactionSourceModels } from "./compaction-triggers";
45
45
 
46
46
  /** Experimental macOS ChatGPT app-server shim; only a strict optional boolean is accepted. */
47
47
  export const chatgptDesktopSchema = z.object({ appServerShim: z.boolean().optional() }).strict();
@@ -53,6 +53,8 @@ export function isUsableApiKeySecret(value: unknown): value is string {
53
53
 
54
54
  export const compactionRoutingSchema = z.object({
55
55
  model: z.string().trim().min(1),
56
+ sourceModels: z.array(z.string()).refine(validCompactionSourceModels,
57
+ "sourceModels requires unique exact selectors or provider/* patterns").optional(),
56
58
  reasoningEffort: z.string().refine(value => pinnedReasoningEffortConfigError(value) === null).optional(),
57
59
  triggers: z.array(z.enum(COMPACTION_TRIGGERS)).nonempty()
58
60
  .refine(values => new Set(values).size === values.length, "triggers must not repeat a value")
@@ -327,6 +329,8 @@ export const providerConfigSchema = z.object({
327
329
  modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(),
328
330
  modelSuppressSyntheticMax: z.record(z.string().min(1), z.boolean()).optional(),
329
331
  preserveResponsesReasoningContent: z.boolean().optional(),
332
+ preserveResponsesInputItemIds: z.boolean().optional(),
333
+ preserveResponsesMessageMetadata: z.boolean().optional(),
330
334
  dropResponsesReasoningItems: z.boolean().optional(),
331
335
  modelReasoningEffortsAuthoritative: z.boolean().optional(),
332
336
  decodesNativeCompactionBlobs: z.boolean().optional(),