@bitkyc08/opencodex 2.63.0 → 2.64.0-preview.20260923

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 (262) hide show
  1. package/AGENTS_INSTALL.md +4 -3
  2. package/README.md +46 -33
  3. package/assets/download-linux.svg +10 -0
  4. package/assets/download-macos.svg +10 -0
  5. package/assets/download-windows.svg +10 -0
  6. package/bin/ocx.mjs +12 -4
  7. package/gui/dist/assets/App-D5eN1TID.js +50 -0
  8. package/gui/dist/assets/App-I5AnaSLh.css +1 -0
  9. package/gui/dist/assets/{Tray-CncKDBTp.js → Tray-Bh_ErQDh.js} +1 -1
  10. package/gui/dist/assets/index-BEq4OCOz.js +86 -0
  11. package/gui/dist/assets/index-DiBRuK-d.css +1 -0
  12. package/gui/dist/assets/usage-companion-chart-DBG_37kQ.js +1 -0
  13. package/gui/dist/index.html +2 -2
  14. package/native/remote-workspace-helper/src/protocol.rs +5 -0
  15. package/package.json +4 -1
  16. package/src/adapters/anthropic.ts +66 -7
  17. package/src/adapters/codebuddy/adapter.ts +112 -16
  18. package/src/adapters/codebuddy/mcp-server.ts +180 -0
  19. package/src/adapters/codebuddy/scaffold-guard.ts +25 -8
  20. package/src/adapters/codebuddy/tool-bridge.ts +597 -0
  21. package/src/adapters/coding-agent/protocol.ts +123 -10
  22. package/src/adapters/coding-agent/turn.ts +324 -2
  23. package/src/adapters/command-code-restored-schema.ts +112 -0
  24. package/src/adapters/command-code-tool-text.ts +589 -0
  25. package/src/adapters/command-code.ts +80 -6
  26. package/src/adapters/cursor/catalog.ts +12 -0
  27. package/src/adapters/cursor/current-request.ts +46 -0
  28. package/src/adapters/cursor/discovery.ts +1 -1
  29. package/src/adapters/cursor/effort-map.ts +4 -0
  30. package/src/adapters/cursor/native-exec.ts +15 -0
  31. package/src/adapters/cursor/protobuf-events.ts +40 -12
  32. package/src/adapters/cursor/protobuf-request.ts +83 -18
  33. package/src/adapters/cursor/request-builder.ts +5 -4
  34. package/src/adapters/cursor/tool-guidance.ts +1 -1
  35. package/src/adapters/devin/live-models.ts +7 -0
  36. package/src/adapters/inline-think-tags.ts +251 -0
  37. package/src/adapters/kiro/adapter.ts +1 -0
  38. package/src/adapters/kiro/stream.ts +5 -3
  39. package/src/adapters/kiro/usage.ts +4 -3
  40. package/src/adapters/mimo-free.ts +37 -18
  41. package/src/adapters/openai-chat/messages.ts +5 -5
  42. package/src/adapters/openai-chat/tool-schema.ts +124 -2
  43. package/src/adapters/openai-chat.ts +13 -3
  44. package/src/adapters/openai-responses/passthrough.ts +15 -2
  45. package/src/adapters/openai-responses/reasoning.ts +14 -1
  46. package/src/adapters/openai-responses/request-strips.ts +29 -3
  47. package/src/adapters/openai-responses/tool-output-recovery.ts +9 -3
  48. package/src/adapters/qoder/adapter.ts +15 -12
  49. package/src/adapters/qoder/scaffold-guard.ts +45 -13
  50. package/src/bridge/internal.ts +20 -0
  51. package/src/bridge/sse.ts +14 -5
  52. package/src/claude/agents-inject.ts +2 -1
  53. package/src/claude/desktop-applied-marker.ts +44 -0
  54. package/src/claude/desktop-profile.ts +22 -12
  55. package/src/claude/inbound.ts +8 -3
  56. package/src/cli/access.ts +22 -5
  57. package/src/cli/account-api.ts +6 -0
  58. package/src/cli/account-auth.ts +5 -2
  59. package/src/cli/account.ts +5 -6
  60. package/src/cli/aside-profiles.ts +61 -1
  61. package/src/cli/claude-desktop.ts +3 -2
  62. package/src/cli/claude.ts +3 -1
  63. package/src/cli/codex-cli-update.ts +2 -2
  64. package/src/cli/codex-shim-autorestore.ts +3 -0
  65. package/src/cli/dispatch.ts +12 -4
  66. package/src/cli/index.ts +98 -10
  67. package/src/cli/registry.ts +1 -1
  68. package/src/cli/resolve.ts +113 -1
  69. package/src/cli/root.ts +5 -0
  70. package/src/cli/stop-approval.ts +186 -0
  71. package/src/cli/stop-report.ts +1 -1
  72. package/src/cli/system-restart-client.ts +19 -4
  73. package/src/client/hub-client.ts +25 -18
  74. package/src/clients/config-export.ts +12 -4
  75. package/src/codex/account-auto-switch.ts +59 -0
  76. package/src/codex/account-lifecycle.ts +7 -0
  77. package/src/codex/account-priority.ts +10 -0
  78. package/src/codex/account-usability.ts +9 -0
  79. package/src/codex/auth-api/account-list.ts +5 -0
  80. package/src/codex/auth-api/login-flow.ts +15 -5
  81. package/src/codex/auth-api/login-state.ts +5 -16
  82. package/src/codex/auth-api/pool-mode-gate.ts +30 -7
  83. package/src/codex/auth-api/routes.ts +39 -3
  84. package/src/codex/auth-context.ts +145 -17
  85. package/src/codex/catalog/build-entries.ts +2 -1
  86. package/src/codex/catalog/effort.ts +8 -2
  87. package/src/codex/catalog/metadata.ts +33 -7
  88. package/src/codex/catalog/native-models.ts +102 -5
  89. package/src/codex/catalog/parsing.ts +2 -0
  90. package/src/codex/catalog/provider-models.ts +19 -6
  91. package/src/codex/cli-installation-targets.ts +44 -40
  92. package/src/codex/codex-write-lock.ts +32 -2
  93. package/src/codex/inject/multi-agent-v2.ts +90 -0
  94. package/src/codex/inject/plan.ts +365 -0
  95. package/src/codex/inject.ts +237 -362
  96. package/src/codex/log-guard/maintenance.ts +30 -13
  97. package/src/codex/project-config-warnings.ts +47 -7
  98. package/src/codex/prompt-layers/encoding.ts +41 -0
  99. package/src/codex/prompt-layers/toml-read.ts +1 -1
  100. package/src/codex/prompt-layers.ts +17 -13
  101. package/src/codex/prompt-text-probe.ts +20 -10
  102. package/src/codex/quota-observation-freshness.ts +34 -0
  103. package/src/codex/quota-rejection.ts +8 -4
  104. package/src/codex/quota.ts +4 -2
  105. package/src/codex/routing/selection.ts +32 -9
  106. package/src/codex/routing.ts +53 -11
  107. package/src/codex/subagent-model-fallback.ts +4 -3
  108. package/src/codex/sync.ts +11 -0
  109. package/src/codex/windows-installation-files.ts +33 -11
  110. package/src/codex/write-coordination.ts +4 -0
  111. package/src/combos/request.ts +6 -2
  112. package/src/companion/settings.ts +6 -1
  113. package/src/config/atomic-write.ts +12 -1
  114. package/src/config/derived-registries.ts +29 -0
  115. package/src/config/diagnostics.ts +17 -0
  116. package/src/config/live-reconcile.ts +11 -2
  117. package/src/config/load-degrade.ts +9 -2
  118. package/src/config/persist-unlocked.ts +4 -3
  119. package/src/config/persisted-mutation.ts +94 -0
  120. package/src/config/provider-validation.ts +1 -1
  121. package/src/config/proxy-env.ts +62 -12
  122. package/src/config/rebase-provenance.ts +80 -1
  123. package/src/config/schema/config-schema.ts +5 -0
  124. package/src/config/schema/leaf-validators.ts +50 -1
  125. package/src/config/subagent-models.ts +41 -15
  126. package/src/config.ts +18 -95
  127. package/src/generated/compatibility-version.json +330 -218
  128. package/src/generated/model-metadata.ts +8 -5
  129. package/src/github/star-state.ts +46 -6
  130. package/src/grok/inject.ts +74 -40
  131. package/src/grok/status.ts +11 -4
  132. package/src/integrations/cursor-effort-table.ts +50 -13
  133. package/src/integrations/raycast-detect.ts +19 -4
  134. package/src/integrations/serialize.ts +11 -1
  135. package/src/lib/bounded-body.ts +30 -0
  136. package/src/lib/crash-guard.ts +52 -4
  137. package/src/lib/local-aside-sync-contract.ts +41 -0
  138. package/src/lib/package-tree-integrity.ts +181 -9
  139. package/src/lib/proxy-env.ts +18 -1
  140. package/src/lib/request-failure-attribution.ts +1 -0
  141. package/src/lib/request-failure-model.ts +3 -0
  142. package/src/lib/service-secrets.ts +99 -2
  143. package/src/lib/socks5-fetch.ts +43 -14
  144. package/src/lib/token-estimate.ts +17 -2
  145. package/src/oauth/command-code.ts +3 -2
  146. package/src/oauth/generic-account-failover.ts +29 -1
  147. package/src/oauth/index.ts +24 -16
  148. package/src/oauth/login-flow-state.ts +5 -5
  149. package/src/oauth/meta-muse-device.ts +49 -7
  150. package/src/oauth/store.ts +74 -9
  151. package/src/providers/alibaba-region-backup.ts +16 -1
  152. package/src/providers/anthropic-fast.ts +89 -0
  153. package/src/providers/anthropic-reset-grant-ledger.ts +288 -0
  154. package/src/providers/anthropic-reset-grants.ts +329 -0
  155. package/src/providers/claude-cli-identity.ts +12 -0
  156. package/src/providers/command-code-efforts.ts +184 -102
  157. package/src/providers/derive.ts +11 -3
  158. package/src/providers/fastwire.ts +16 -3
  159. package/src/providers/label.ts +8 -3
  160. package/src/providers/model-rename-fields.ts +1 -0
  161. package/src/providers/openai-virtual-models.ts +1 -0
  162. package/src/providers/quota/vendor-probes-oauth.ts +2 -1
  163. package/src/providers/reasoning-metadata.ts +38 -15
  164. package/src/providers/registry/entries-core.ts +95 -7
  165. package/src/providers/registry/entries-extended.ts +21 -10
  166. package/src/providers/registry/model-ids.ts +1 -0
  167. package/src/providers/registry/model-seeds.ts +31 -3
  168. package/src/providers/registry/types.ts +3 -1
  169. package/src/providers/resolved-model-policy-merge.ts +38 -8
  170. package/src/providers/resolved-model-policy.ts +4 -2
  171. package/src/providers/service-tier.ts +3 -1
  172. package/src/reasoning-effort.ts +6 -5
  173. package/src/responses/code-mode-helper-compat.ts +14 -4
  174. package/src/responses/code-mode-shell-input.ts +54 -0
  175. package/src/responses/custom-tool-compat.ts +173 -5
  176. package/src/responses/parser.ts +38 -2
  177. package/src/responses/reasoning-replay-cache.ts +26 -0
  178. package/src/router.ts +11 -1
  179. package/src/routing/history/indexer.ts +7 -2
  180. package/src/routing/history/schema.ts +3 -1
  181. package/src/server/adapter-resolve.ts +2 -2
  182. package/src/server/auth-cors.ts +3 -0
  183. package/src/server/chat-completions.ts +14 -1
  184. package/src/server/chat-native.ts +17 -0
  185. package/src/server/claude-messages.ts +4 -3
  186. package/src/server/direct-local-http.ts +45 -19
  187. package/src/server/gui-session.ts +6 -15
  188. package/src/server/index/package-tree-guard.ts +53 -0
  189. package/src/server/index/serve-options.ts +17 -3
  190. package/src/server/index/startup-warnings.ts +17 -0
  191. package/src/server/index.ts +15 -17
  192. package/src/server/lifecycle.ts +8 -0
  193. package/src/server/live.ts +54 -4
  194. package/src/server/management/agent-settings-routes.ts +44 -8
  195. package/src/server/management/anthropic-reset-grant-routes.ts +252 -0
  196. package/src/server/management/codex-prompt-routes.ts +5 -1
  197. package/src/server/management/config-routes.ts +14 -4
  198. package/src/server/management/oauth-account-routes.ts +17 -1
  199. package/src/server/management/provider-overwrite-carry.ts +164 -0
  200. package/src/server/management/provider-routes.ts +36 -6
  201. package/src/server/management/remote-workspace-routes.ts +2 -2
  202. package/src/server/management/route-registry.ts +2 -0
  203. package/src/server/management/shadow-call-validation.ts +39 -0
  204. package/src/server/management/system-restart.ts +72 -6
  205. package/src/server/management-api.ts +16 -2
  206. package/src/server/management-auth.ts +52 -8
  207. package/src/server/proxy-liveness.ts +110 -4
  208. package/src/server/request-log.ts +26 -9
  209. package/src/server/request-metrics.ts +5 -0
  210. package/src/server/responses/adapter-continuation.ts +6 -3
  211. package/src/server/responses/adapter-dispatch.ts +65 -1
  212. package/src/server/responses/compact.ts +31 -1
  213. package/src/server/responses/compaction-routing.ts +28 -3
  214. package/src/server/responses/core-codex-account.ts +85 -26
  215. package/src/server/responses/core-combo-failure.ts +16 -15
  216. package/src/server/responses/core-normalize.ts +5 -1
  217. package/src/server/responses/core-opaque-recovery.ts +50 -2
  218. package/src/server/responses/core-options.ts +2 -0
  219. package/src/server/responses/core-replay.ts +7 -0
  220. package/src/server/responses/core.ts +1 -1
  221. package/src/server/responses/encrypted-payload.ts +23 -9
  222. package/src/server/responses/passthrough-delivery.ts +55 -35
  223. package/src/server/responses/passthrough-dispatch.ts +12 -13
  224. package/src/server/responses/policy-fallback.ts +12 -1
  225. package/src/server/responses/request-prepare.ts +46 -7
  226. package/src/server/responses/request-spend.ts +31 -15
  227. package/src/server/responses/run-turn-execution.ts +6 -5
  228. package/src/server/responses/shadow-target-availability.ts +61 -0
  229. package/src/server/responses-custom-tool-repair.ts +2 -0
  230. package/src/service/claim.ts +185 -0
  231. package/src/service/cli.ts +10 -1
  232. package/src/service/guarded-manager-target.ts +151 -0
  233. package/src/service/guards.ts +47 -48
  234. package/src/service/managing-cli.ts +170 -0
  235. package/src/service/orchestration.ts +3 -1
  236. package/src/service/state.ts +4 -0
  237. package/src/service/systemd.ts +16 -1
  238. package/src/service.ts +1 -1
  239. package/src/types/config.ts +8 -0
  240. package/src/types/provider.ts +16 -0
  241. package/src/types/request.ts +10 -0
  242. package/src/types/tools.ts +9 -1
  243. package/src/types/wire.ts +68 -4
  244. package/src/types.ts +1 -0
  245. package/src/update/job.ts +10 -16
  246. package/src/update/npm-invocation.mjs +17 -16
  247. package/src/update/transactional-install.d.mts +22 -1
  248. package/src/update/transactional-install.mjs +176 -19
  249. package/src/update/update-failure-guidance.d.mts +8 -0
  250. package/src/update/update-failure-guidance.mjs +47 -0
  251. package/src/usage/expected-prices.ts +54 -12
  252. package/src/usage/log.ts +117 -3
  253. package/src/usage/telemetry-contract.ts +1 -0
  254. package/src/usage/timeline.ts +34 -6
  255. package/src/usage/user-cost-overlay-reconciler.ts +3 -3
  256. package/src/vision/reasoning.ts +2 -4
  257. package/src/web-search/xai-executor.ts +14 -4
  258. package/gui/dist/assets/App-BqrsSrIR.js +0 -50
  259. package/gui/dist/assets/index-C6SJrh0N.js +0 -86
  260. package/gui/dist/assets/index-DdDunwDb.css +0 -1
  261. package/gui/dist/assets/usage-companion-chart-CzAAAB1o.js +0 -1
  262. package/src/adapters/kiro-thinking.ts +0 -112
@@ -6,14 +6,8 @@ import { clearableDeadline } from "../lib/abort";
6
6
  import type { Desktop3pModelEntry } from "../claude/desktop-3p";
7
7
  import { assertDesktop3pModelsValid } from "../claude/desktop-3p-guard";
8
8
 
9
- /**
10
- * A pairing grant may cross loopback or authenticated HTTPS, and nothing else.
11
- *
12
- * Mirrors the hub-side rule in src/server/gui-session.ts. Checking here too is not
13
- * redundant: it keeps the client from spending a single-use code on a request the hub is
14
- * certain to refuse.
15
- */
16
- function isPairingTransportPermitted(origin: string): boolean {
9
+ /** Hub traffic may cross loopback or authenticated HTTPS, and nothing else. */
10
+ function isHubTransportPermitted(origin: string): boolean {
17
11
  let url: URL;
18
12
  try {
19
13
  url = new URL(origin);
@@ -103,7 +97,7 @@ async function fetchBounded(
103
97
  });
104
98
  headerDeadline?.clear();
105
99
  if (response.status >= 300 && response.status < 400 && response.status !== 304) {
106
- try { await response.body?.cancel(); } catch { /* best effort */ }
100
+ try { void response.body?.cancel().catch(() => {}); } catch { /* best effort */ }
107
101
  throw new HubClientError("redirect_refused", "Hub request redirect was refused", response.status);
108
102
  }
109
103
  return response;
@@ -118,15 +112,16 @@ async function fetchBounded(
118
112
  async function boundedText(
119
113
  response: Response,
120
114
  maxBytes: number,
121
- options: { inactivityTimeoutMs?: number } = {},
115
+ options: { signal?: AbortSignal; inactivityTimeoutMs?: number } = {},
122
116
  ): Promise<string> {
123
117
  const declared = Number(response.headers.get("content-length") ?? "0");
124
118
  if (Number.isFinite(declared) && declared > maxBytes) {
125
- try { await response.body?.cancel(); } catch { /* best effort */ }
119
+ try { void response.body?.cancel().catch(() => {}); } catch { /* best effort */ }
126
120
  throw new HubClientError("body_too_large", "Hub response exceeded the allowed size", response.status);
127
121
  }
128
122
  const result = await readBoundedResponseBytes(response, {
129
123
  maxBytes,
124
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
130
125
  ...(options.inactivityTimeoutMs === undefined ? {} : { inactivityTimeoutMs: options.inactivityTimeoutMs }),
131
126
  });
132
127
  if (result.oversized) {
@@ -193,6 +188,12 @@ export function normalizeHubOrigin(input: string): string {
193
188
  "Hub URL must be an HTTP(S) origin without credentials, query, fragment, or non-/v1 path",
194
189
  );
195
190
  }
191
+ if (!isHubTransportPermitted(parsed.origin)) {
192
+ throw new HubClientError(
193
+ "insecure_http_refused",
194
+ "Hub URLs require loopback or HTTPS; plaintext remote HTTP is not permitted",
195
+ );
196
+ }
196
197
  return parsed.origin;
197
198
  }
198
199
 
@@ -252,7 +253,7 @@ export async function exchangeConnectPairingGrant(
252
253
  // Deliberateness is not the control that matters: the grant is readable by anything on the
253
254
  // path and the session it mints is reusable. The hub refuses this exchange outright now, so
254
255
  // sending it would only burn a single-use code against a certain rejection.
255
- if (!isPairingTransportPermitted(origin)) {
256
+ if (!isHubTransportPermitted(origin)) {
256
257
  throw new HubClientError("insecure_http_refused", "Pairing requires loopback or HTTPS; plaintext HTTP cannot carry a grant");
257
258
  }
258
259
  const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/opencodex-session`, {
@@ -446,20 +447,26 @@ export async function downloadClientCatalog(
446
447
  headers,
447
448
  }, options.timeoutMs, "headers");
448
449
  if (response.status === 304) {
450
+ try { void response.body?.cancel().catch(() => {}); } catch { /* best effort */ }
449
451
  throw new HubClientError("catalog_unexpected_304", "Hub answered 304 to an unconditional catalog request", 304);
450
452
  }
451
453
  if (!response.ok) {
454
+ try { void response.body?.cancel().catch(() => {}); } catch { /* best effort */ }
452
455
  const code = response.status === 401 ? "catalog_unauthorized" : `catalog_http_${response.status}`;
453
456
  throw new HubClientError(code, `Hub catalog request failed (${response.status})`, response.status);
454
457
  }
455
458
  if (!jsonCompatibleContentType(response)) {
456
- try { await response.body?.cancel(); } catch { /* best effort */ }
459
+ try { void response.body?.cancel().catch(() => {}); } catch { /* best effort */ }
457
460
  throw new HubClientError("catalog_content_type_invalid", "Hub catalog response was not JSON", response.status);
458
461
  }
459
462
  let body: string;
460
463
  try {
464
+ const inactivityTimeoutMs = safeTimeout(options.timeoutMs);
461
465
  body = await boundedText(response, options.maxBytes ?? MAX_REMOTE_CATALOG_BYTES, {
462
- inactivityTimeoutMs: safeTimeout(options.timeoutMs),
466
+ // Permit active catalog transfers to span multiple inactivity windows,
467
+ // while retaining the client's established maximum request lifetime.
468
+ signal: AbortSignal.timeout(Math.min(inactivityTimeoutMs * 24, 120_000)),
469
+ inactivityTimeoutMs,
463
470
  });
464
471
  } catch (error) {
465
472
  if (error instanceof DOMException && error.name === "TimeoutError") {
@@ -481,7 +488,7 @@ export async function fetchHubUsage(
481
488
  options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
482
489
  ): Promise<HubUsageReport> {
483
490
  const origin = normalizeHubOrigin(serverUrl);
484
- if (!isPairingTransportPermitted(origin)) {
491
+ if (!isHubTransportPermitted(origin)) {
485
492
  throw new HubClientError("insecure_http_refused", "Client usage requires HTTPS or loopback HTTP");
486
493
  }
487
494
  const response = await fetchBounded(options.fetchImpl ?? fetch, `${origin}/v1/usage?${query}`, {
@@ -593,7 +600,7 @@ export async function downloadDesktop3pModels(
593
600
  options: { timeoutMs?: number; fetchImpl?: typeof fetch } = {},
594
601
  ): Promise<{ version: 1; models: Desktop3pModelEntry[] }> {
595
602
  const origin = normalizeHubOrigin(serverUrl);
596
- if (!isPairingTransportPermitted(origin)) {
603
+ if (!isHubTransportPermitted(origin)) {
597
604
  throw new HubClientError("insecure_http_refused", "Desktop model snapshots require HTTPS or loopback HTTP");
598
605
  }
599
606
  try {
@@ -608,11 +615,11 @@ export async function downloadDesktop3pModels(
608
615
  }),
609
616
  }, options.timeoutMs);
610
617
  if (!response.ok || response.status === 304) {
611
- try { await response.body?.cancel(); } catch { /* best effort */ }
618
+ try { void response.body?.cancel().catch(() => {}); } catch { /* best effort */ }
612
619
  throw new HubClientError(`desktop_snapshot_http_${response.status}`, "Hub Desktop model snapshot request failed", response.status);
613
620
  }
614
621
  if (!jsonCompatibleContentType(response)) {
615
- try { await response.body?.cancel(); } catch { /* best effort */ }
622
+ try { void response.body?.cancel().catch(() => {}); } catch { /* best effort */ }
616
623
  throw new HubClientError("desktop_snapshot_invalid", "Hub Desktop model snapshot was invalid");
617
624
  }
618
625
  const body = await boundedText(response, DESKTOP_SNAPSHOT_MAX_BYTES, {
@@ -985,9 +985,15 @@ function buildPiClientConfig(ctx: ExportContext, sendSessionAffinityHeaders = fa
985
985
  };
986
986
  }
987
987
 
988
+ /** Do not let provider-controlled catalog text become an environment lookup. */
989
+ function containsEnvInterpolation(value: string): boolean {
990
+ return value.includes("${");
991
+ }
992
+
988
993
  function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig {
989
994
  const models: Record<string, HermesModelEntry> = {};
990
995
  for (const model of normalizeExportModels(ctx.models)) {
996
+ if (containsEnvInterpolation(model.namespaced)) continue;
991
997
  const declared = model.inputModalities;
992
998
  models[model.namespaced] = declared && declared.length > 0
993
999
  ? { supports_vision: declared.includes("image") }
@@ -1009,15 +1015,17 @@ function buildHermesClientConfig(ctx: ExportContext): HermesGeneratedConfig {
1009
1015
  }
1010
1016
 
1011
1017
  function buildOpenclawClientConfig(ctx: ExportContext): OpenclawGeneratedConfig {
1012
- const models: OpenclawModelEntry[] = normalizeExportModels(ctx.models).map(model => {
1018
+ const models: OpenclawModelEntry[] = normalizeExportModels(ctx.models).flatMap(model => {
1019
+ const name = exportModelLabel(model);
1020
+ if (containsEnvInterpolation(model.namespaced) || containsEnvInterpolation(name)) return [];
1013
1021
  const context = authoritativeContextWindow(model.contextWindow);
1014
1022
  const input = [...new Set(model.inputModalities?.filter(value => ["text", "image", "video", "audio"].includes(value)))];
1015
- return {
1023
+ return [{
1016
1024
  id: model.namespaced,
1017
- name: exportModelLabel(model),
1025
+ name,
1018
1026
  ...(context !== undefined ? { contextWindow: context } : {}),
1019
1027
  ...(input.length > 0 ? { input } : {}),
1020
- };
1028
+ }];
1021
1029
  });
1022
1030
  const headers = proxyAdmissionHeaders(ctx.config, OPENCLAW_API_KEY_ENV_REF);
1023
1031
  return {
@@ -0,0 +1,59 @@
1
+ import type { OcxConfig } from "../types";
2
+ import { deleteConfigObjectChildKey } from "../config/rebase-provenance";
3
+ import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
4
+
5
+ export const DEFAULT_CODEX_AUTO_SWITCH_THRESHOLD = 80;
6
+ export const MIN_CODEX_AUTO_SWITCH_THRESHOLD = 0;
7
+ export const MAX_CODEX_AUTO_SWITCH_THRESHOLD = 100;
8
+
9
+ export function parseCodexAutoSwitchThreshold(value: unknown): number | null {
10
+ return typeof value === "number"
11
+ && Number.isInteger(value)
12
+ && value >= MIN_CODEX_AUTO_SWITCH_THRESHOLD
13
+ && value <= MAX_CODEX_AUTO_SWITCH_THRESHOLD
14
+ ? value
15
+ : null;
16
+ }
17
+
18
+ export function isCodexAccountAutoSwitchThresholdKey(key: unknown): key is string {
19
+ return key === MAIN_CODEX_ACCOUNT_ID || isValidCodexAccountId(key);
20
+ }
21
+
22
+ /** Null means this account inherits the global threshold. */
23
+ export function getCodexAccountAutoSwitchThresholdOverride(
24
+ config: OcxConfig,
25
+ accountId: string,
26
+ ): number | null {
27
+ const thresholds = config.codexAccountAutoSwitchThresholds;
28
+ if (!thresholds || !Object.hasOwn(thresholds, accountId)) return null;
29
+ return parseCodexAutoSwitchThreshold(thresholds[accountId]);
30
+ }
31
+
32
+ /** Source-account threshold used by every usage-driven routing decision. */
33
+ export function getEffectiveCodexAutoSwitchThreshold(
34
+ config: OcxConfig,
35
+ accountId: string,
36
+ ): number {
37
+ const override = getCodexAccountAutoSwitchThresholdOverride(config, accountId);
38
+ if (override !== null) return override;
39
+ return config.autoSwitchThreshold ?? DEFAULT_CODEX_AUTO_SWITCH_THRESHOLD;
40
+ }
41
+
42
+ /** Store a concrete override, or null to restore global inheritance. */
43
+ export function setCodexAccountAutoSwitchThresholdOverride(
44
+ config: OcxConfig,
45
+ accountId: string,
46
+ threshold: number | null,
47
+ ): void {
48
+ if (threshold === null) {
49
+ deleteConfigObjectChildKey(config, "codexAccountAutoSwitchThresholds", accountId);
50
+ return;
51
+ }
52
+ const entries = new Map(Object.entries(config.codexAccountAutoSwitchThresholds ?? {}));
53
+ entries.set(accountId, threshold);
54
+ config.codexAccountAutoSwitchThresholds = Object.fromEntries(entries);
55
+ }
56
+
57
+ export function forgetCodexAccountAutoSwitchThreshold(config: OcxConfig, accountId: string): void {
58
+ setCodexAccountAutoSwitchThresholdOverride(config, accountId, null);
59
+ }
@@ -5,6 +5,7 @@ import {
5
5
  saveConfigPreservingClaudeCode,
6
6
  withConfigMutationLockSync,
7
7
  } from "../config";
8
+ import { captureConfigTopLevelRollback } from "../config/rebase-provenance";
8
9
  import { removeCodexAccountCredential } from "./account-store";
9
10
  import { clearAccountNeedsReauth } from "./account-runtime-state";
10
11
  import { getMainChatgptAccountId, readCodexTokensResult } from "./auth-collision";
@@ -19,6 +20,7 @@ import { extractAccountIdClaims } from "../oauth/chatgpt";
19
20
  import { forgetCodexAccountPause } from "./account-pause";
20
21
  import { clearCodexAccountPin, forgetCodexAccountPriority } from "./account-priority";
21
22
  import { forgetCodexQuotaAutoRefreshAccount } from "./quota-auto-refresh-state";
23
+ import { forgetCodexAccountAutoSwitchThreshold } from "./account-auto-switch";
22
24
  import { codexAccountNamespaceEntries, codexAccountPickerEnabled } from "./account-namespaces";
23
25
  import type { OcxConfig } from "../types";
24
26
 
@@ -163,6 +165,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string):
163
165
  let cleanupFailed = false;
164
166
  const pickerVisibilityChanged = withConfigMutationLockSync(() => {
165
167
  const previousConfig = structuredClone(runtimeConfig);
168
+ const restoreDeletionProvenance = captureConfigTopLevelRollback(runtimeConfig, []);
166
169
  const configPath = getConfigPath();
167
170
  const hasPersistedConfig = existsSync(configPath);
168
171
  const previousPersistedConfig = hasPersistedConfig ? readFileSync(configPath) : undefined;
@@ -177,6 +180,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string):
177
180
  .filter(account => account.isMain || account.id !== accountId);
178
181
  forgetCodexAccountPause(runtimeConfig, accountId);
179
182
  forgetCodexAccountPriority(runtimeConfig, accountId);
183
+ forgetCodexAccountAutoSwitchThreshold(runtimeConfig, accountId);
180
184
  if (runtimeConfig.codexQuotaAutoRefresh?.[accountId]) {
181
185
  const retained = { ...runtimeConfig.codexQuotaAutoRefresh };
182
186
  delete retained[accountId];
@@ -193,6 +197,9 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string):
193
197
  saveConfigPreservingClaudeCode(runtimeConfig);
194
198
  } catch (error) {
195
199
  restoreRuntimeConfig(runtimeConfig, previousConfig);
200
+ // The value snapshot cannot restore WeakMap-backed deletion intent. Retaining a
201
+ // rejected reset would erase a later disk override when this account inherited.
202
+ restoreDeletionProvenance();
196
203
  try {
197
204
  assertPersistedConfigUnchanged(configPath, previousPersistedConfig);
198
205
  } catch {
@@ -2,6 +2,16 @@ import { isValidCodexAccountId, MAIN_CODEX_ACCOUNT_ID } from "./account-id";
2
2
  import { DEFAULT_ACCOUNT_PRIORITY, normalizeAccountPriority } from "./pool-rotation";
3
3
  import type { OcxConfig } from "../types";
4
4
  import { deleteConfigTopLevelKey } from "../config/rebase-provenance";
5
+ import { getEffectiveCodexAutoSwitchThreshold } from "./account-auto-switch";
6
+
7
+ /** Shared cadence for the opt-in live priority recheck and its observation freshness. */
8
+ export const CODEX_PRIORITY_FAILBACK_REFRESH_MS = 5 * 60_000;
9
+
10
+ export function codexAccountPriorityFailbackEnabled(config: OcxConfig, accountId: string): boolean {
11
+ return config.codexAccountPriorityFailback === true
12
+ && (config.accountPoolStrategy ?? "quota") === "quota"
13
+ && getEffectiveCodexAutoSwitchThreshold(config, accountId) > 0;
14
+ }
5
15
 
6
16
  /**
7
17
  * Which ids may carry a selection order: any pool account, plus the synthetic
@@ -39,6 +39,15 @@ export interface CodexAccountUsabilityOptions {
39
39
  * account can always become unable to serve.
40
40
  */
41
41
  retainAccountForUploadedFiles?: boolean;
42
+ /**
43
+ * Main is live for this request only because the request carries its own main bearer (#5019).
44
+ *
45
+ * Main may then win this request's selection, but that credential belongs to the request, not
46
+ * to the Pool: selecting it must never be written back as the shared active account. Persisting
47
+ * it made every later request -- including one whose caller identity is unreadable, or one that
48
+ * owns the cooled subscription -- follow a main the operator never chose.
49
+ */
50
+ requestOwnedMainCredential?: boolean;
42
51
  }
43
52
 
44
53
  /**
@@ -6,6 +6,7 @@ import { ConfigMutationLockError, mutatePersistedConfig } from "../../config";
6
6
  import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
7
7
  import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause";
8
8
  import { getCodexAccountPriority } from "../account-priority";
9
+ import { getCodexAccountAutoSwitchThresholdOverride } from "../account-auto-switch";
9
10
  import { clearThreadAccountMapForAccount, isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion } from "../routing";
10
11
  import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan";
11
12
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
@@ -134,6 +135,7 @@ export function poolAccountDto(
134
135
  isMain: false,
135
136
  paused,
136
137
  priority,
138
+ autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(config, account.id),
137
139
  quota: quota ? { ...quota } : null,
138
140
  needsReauth: needsReauth || health.status === "reauth_required",
139
141
  ...(reauthReason !== undefined ? { reauthReason } : {}),
@@ -157,6 +159,8 @@ export interface CodexAuthAccountDto {
157
159
  paused: boolean;
158
160
  /** Selection order; higher is used earlier. Always present, 0 when unset. */
159
161
  priority: number;
162
+ /** Null inherits the global usage-switch threshold; 0 disables it for this account. */
163
+ autoSwitchThresholdOverride: number | null;
160
164
  quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
161
165
  needsReauth?: boolean;
162
166
  /**
@@ -353,6 +357,7 @@ export async function listCodexAuthAccountsSnapshot(
353
357
  paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
354
358
  mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig),
355
359
  priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
360
+ autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
356
361
  hasCredential: hasMainCredential,
357
362
  needsReauth: mainNeedsReauth,
358
363
  ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
@@ -216,7 +216,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
216
216
  const result = await startLoginFlow("chatgpt", {
217
217
  forceLogin: true,
218
218
  ...(useDeviceFlow ? { flow: "device" as const } : {}),
219
- });
219
+ }, { flowId });
220
220
 
221
221
  // Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
222
222
  // The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
@@ -532,11 +532,21 @@ export async function handleCodexAuthLoginCode(req: Request): Promise<Response>
532
532
  }
533
533
 
534
534
  export async function handleCodexAuthLoginCancel(req: Request): Promise<Response> {
535
- const body = (await req.json().catch(() => ({}))) as { flowId?: string };
535
+ const body: unknown = await req.json().catch(() => null);
536
+ const suppliedId = body && typeof body === "object" && !Array.isArray(body)
537
+ ? (body as { flowId?: unknown }).flowId : undefined;
538
+ const flowId = typeof suppliedId === "string" ? suppliedId.trim() : "";
539
+ if (!flowId) return jsonResponse({ error: "flowId required" }, 400);
536
540
  const { cancelLoginFlow } = await import("../../oauth");
537
- const cancelled = cancelLoginFlow("chatgpt");
538
- expireCodexAuthFlow(body.flowId ?? null);
539
- return jsonResponse({ ok: true, cancelled });
541
+ const flow = codexAuthLoginState.get(flowId);
542
+ if (!flow || flow.status !== "pending") {
543
+ return jsonResponse({ error: "login flow expired or unknown" }, 400);
544
+ }
545
+ if (!cancelLoginFlow("chatgpt", flowId)) {
546
+ return jsonResponse({ error: "login flow expired or unknown" }, 400);
547
+ }
548
+ expireCodexAuthFlow(flowId);
549
+ return jsonResponse({ ok: true, cancelled: true });
540
550
  }
541
551
 
542
552
  export async function handleCodexAuthLoginStatus(req: Request, url: URL, config: OcxConfig): Promise<Response> {
@@ -36,21 +36,11 @@ export function pruneCodexLoginState(now = Date.now()): void {
36
36
  }
37
37
  }
38
38
 
39
- export function expireCodexAuthFlow(flowId: string | null, error = "Login cancelled"): void {
40
- const ids = flowId
41
- ? [flowId]
42
- : [...codexAuthLoginState].filter(([, state]) => state.status === "pending").map(([id]) => id);
43
- for (const id of ids) {
44
- let owner = codexAuthLoginState.get(id);
45
- if (!owner) {
46
- pruneCodexLoginState();
47
- if (codexAuthLoginState.size >= MAX_CODEX_LOGIN_STATE_ROWS) continue;
48
- owner = { status: "error", startedAt: Date.now() };
49
- codexAuthLoginState.set(id, owner);
50
- }
51
- Object.assign(owner, { status: "error", error, doneAt: Date.now() });
52
- setTimeout(() => { if (codexAuthLoginState.get(id) === owner) codexAuthLoginState.delete(id); }, 30_000);
53
- }
39
+ export function expireCodexAuthFlow(flowId: string, error = "Login cancelled"): void {
40
+ const owner = codexAuthLoginState.get(flowId);
41
+ if (!owner || owner.status !== "pending") return;
42
+ Object.assign(owner, { status: "error", error, doneAt: Date.now() });
43
+ setTimeout(() => { if (codexAuthLoginState.get(flowId) === owner) codexAuthLoginState.delete(flowId); }, 30_000);
54
44
  }
55
45
  /** Package-internal admission-test seam: seed synthetic login-flow rows and return a prefix-scoped cleanup. */
56
46
  export function seedLoginRowsForTests(prefix: string, count: number): () => void {
@@ -61,4 +51,3 @@ export function seedLoginRowsForTests(prefix: string, count: number): () => void
61
51
  for (const key of [...codexAuthLoginState.keys()]) if (key.startsWith(prefix)) codexAuthLoginState.delete(key);
62
52
  };
63
53
  }
64
-
@@ -1,10 +1,12 @@
1
+ import { CODEX_PRIORITY_FAILBACK_REFRESH_MS } from "../account-priority";
2
+ import { codexQuotaHasFreshUsage } from "../quota-observation-freshness";
1
3
  import { getCodexAccountCredential, getValidCodexToken, readCodexAccountRecord } from "../account-store";
2
4
  import { getAccountQuota, isCompleteCodexQuotaRecoverySnapshot } from "../quota";
3
5
  import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
4
6
  import { claimDueCodexQuotaRecoveryProbes, settleCodexQuotaRecoveryProbe } from "../routing";
5
7
  import { readCodexTokens } from "../auth-collision";
6
8
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
7
- import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
9
+ import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "../main-account";
8
10
  import { captureConfigGeneration, registerStateSweepAfterTick } from "../../lib/state-store-sweeper";
9
11
  import { captureMainAccountIdentityGeneration, isMainAccountIdentityGenerationLive } from "../main-account-cache";
10
12
  import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
@@ -16,11 +18,12 @@ import type { AdmissionLease } from "../../lib/admission";
16
18
  import { tryAcquireNativeMainProfileClaim } from "../native-main-admission";
17
19
  import { withNativeMainCredentialClaim, isNativeMainClaimUnavailable } from "./http";
18
20
  import type { PoolQuotaResult } from "./pool-quota-probe";
19
- import { fetchMainAccountInfoAttempt, fetchMainAccountInfo } from "./main-account-probe";
21
+ import { fetchMainAccountInfoAttempt, fetchMainAccountInfo, MAIN_CACHE_TTL } from "./main-account-probe";
20
22
  import { fetchPoolAccountQuota, PoolQuotaProbeBusyError, POOL_CACHE_TTL, POOL_QUOTA_REFRESH_CONCURRENCY } from "./pool-quota-probe";
21
23
  import { getRuntimeConfig, configuredPoolAccount, mapWithConcurrency } from "./runtime-config";
22
24
 
23
25
  let primeInFlight: Promise<void> | null = null;
26
+ let lastPriorityFailbackPrimeAt: number | undefined;
24
27
  /**
25
28
  * Last prime attempt per pool account. A failed WHAM lookup stores no quota, so
26
29
  * without this the account stays "unknown" and every later prime trigger re-selects
@@ -176,11 +179,20 @@ export async function primeCodexPoolQuotas(
176
179
  || providerCodexAccountMode(OPENAI_CODEX_PROVIDER_ID, openai) !== "pool"
177
180
  ) return;
178
181
  if (primeInFlight) return primeInFlight;
182
+ // Count attempted passes, including failed reads; incoming traffic must not flood WHAM.
183
+ if (reason === "priority-failback") {
184
+ const now = Date.now();
185
+ if (lastPriorityFailbackPrimeAt !== undefined
186
+ && now - lastPriorityFailbackPrimeAt < CODEX_PRIORITY_FAILBACK_REFRESH_MS) return;
187
+ lastPriorityFailbackPrimeAt = now;
188
+ }
179
189
  primeInFlight = (async () => {
180
190
  const pool = (runtimeConfig.codexAccounts ?? []).filter(isSelectableCodexPoolAccount);
181
191
  const stale = pool.filter(a => {
182
192
  const q = getAccountQuota(a.id);
183
- if (q) return Date.now() - q.updatedAt >= POOL_CACHE_TTL;
193
+ const observationStale = reason === "priority-failback" && q
194
+ && !codexQuotaHasFreshUsage(q, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
195
+ if (q && !observationStale) return Date.now() - q.updatedAt >= POOL_CACHE_TTL;
184
196
  // No stored quota: either never primed, or the last attempt failed. Retry only
185
197
  // once per TTL window so an unreachable or rejecting account cannot turn every
186
198
  // prime trigger into another upstream request.
@@ -199,10 +211,16 @@ export async function primeCodexPoolQuotas(
199
211
  // Keep one local owner and one cross-process reader from physical
200
212
  // identity reconciliation through WHAM and all quota publication.
201
213
  (options.reconcileMainAccount ?? reconcileMainCodexAccountRuntimeState)();
202
- if (getAccountQuota(MAIN_CODEX_ACCOUNT_ID)) return;
214
+ const quota = getAccountQuota(MAIN_CODEX_ACCOUNT_ID);
215
+ const observationStale = reason === "priority-failback" && quota
216
+ && !codexQuotaHasFreshUsage(quota, getMainAccountPlan(), Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
217
+ if (quota && (reason !== "priority-failback"
218
+ || (!observationStale && Date.now() - quota.updatedAt < MAIN_CACHE_TTL))) return;
203
219
  if (!(options.readMainTokens ?? readCodexTokens)()) return;
204
- if (options.fetchMainInfo) await options.fetchMainInfo(false);
205
- else await fetchMainAccountInfoAttempt(false, 1, mainLease, true);
220
+ const bypassCachedQuota = !!observationStale;
221
+ if (options.fetchMainInfo) await options.fetchMainInfo(bypassCachedQuota);
222
+ // Cache bypass is passive observation, never an explicit reauthentication recovery.
223
+ else await fetchMainAccountInfoAttempt(bypassCachedQuota, 1, mainLease, true, false);
206
224
  });
207
225
  } catch (error) {
208
226
  if (!isNativeMainClaimUnavailable(error)) throw error;
@@ -218,7 +236,10 @@ export async function primeCodexPoolQuotas(
218
236
  if (!getCodexAccountCredential(a.id)) return;
219
237
  let result: PoolQuotaResult;
220
238
  try {
221
- result = await fetchPoolAccountQuota(a.id, false, a.plan, getValidPoolTokenForPrime);
239
+ const quota = getAccountQuota(a.id);
240
+ const bypassCachedQuota = reason === "priority-failback" && quota !== null
241
+ && !codexQuotaHasFreshUsage(quota, a.plan, Date.now(), CODEX_PRIORITY_FAILBACK_REFRESH_MS);
242
+ result = await fetchPoolAccountQuota(a.id, bypassCachedQuota, a.plan, getValidPoolTokenForPrime);
222
243
  } catch (error) {
223
244
  // Local quota-flight saturation proves no WHAM request existed for this account.
224
245
  // Consume it per item so sibling workers remain inside the shared prime lifetime.
@@ -257,6 +278,7 @@ export async function primeCodexPoolQuotas(
257
278
  * from another suite cannot coalesce into the next prime. */
258
279
  export function clearCodexQuotaPrimeState(): void {
259
280
  primeInFlight = null;
281
+ lastPriorityFailbackPrimeAt = undefined;
260
282
  poolQuotaPrimeAttemptedAt.clear();
261
283
  getValidPoolTokenForPrime = getValidCodexToken;
262
284
  }
@@ -266,6 +288,7 @@ export function clearCodexQuotaPrimeState(): void {
266
288
  * the throttle a production caller would see. */
267
289
  export function clearCodexQuotaPrimeSingleFlightForTests(): void {
268
290
  primeInFlight = null;
291
+ lastPriorityFailbackPrimeAt = undefined;
269
292
  }
270
293
 
271
294
  /** Test-only reset for the worker-level single-flight. */
@@ -21,6 +21,8 @@ import type { CodexAuthCatalogConvergence } from "./login-flow";
21
21
  import { PoolQuotaProbeBusyError } from "./pool-quota-probe";
22
22
  import { inspectResetCredits, consumeResetCredits } from "./reset-credit-service";
23
23
  import { getRuntimeConfig, saveRuntimeConfig, configuredPoolAccount } from "./runtime-config";
24
+ import { captureConfigTopLevelRollback } from "../../config/rebase-provenance";
25
+ import { getEffectiveCodexAutoSwitchThreshold, isCodexAccountAutoSwitchThresholdKey, parseCodexAutoSwitchThreshold, setCodexAccountAutoSwitchThresholdOverride } from "../account-auto-switch";
24
26
 
25
27
  export async function handleCodexAuthAPI(
26
28
  req: Request,
@@ -272,12 +274,46 @@ export async function handleCodexAuthAPI(
272
274
  }
273
275
 
274
276
  if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") {
275
- let body: { threshold: number };
276
- try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
277
+ let parsedBody: unknown;
278
+ try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
279
+ if (typeof parsedBody !== "object" || parsedBody === null || Array.isArray(parsedBody)) {
280
+ return jsonResponse({ error: "body must be an object" }, 400);
281
+ }
282
+ const body = parsedBody as { id?: unknown; threshold?: unknown };
283
+ const runtimeConfig = getRuntimeConfig(config);
284
+ if (Object.hasOwn(body, "id")) {
285
+ if (!isCodexAccountAutoSwitchThresholdKey(body.id)) {
286
+ return jsonResponse({ error: "id must be a Codex account id" }, 400);
287
+ }
288
+ const threshold = body.threshold === null ? null : parseCodexAutoSwitchThreshold(body.threshold);
289
+ if (body.threshold !== null && threshold === null) {
290
+ return jsonResponse({ error: "threshold must be null or an integer 0-100" }, 400);
291
+ }
292
+ if (body.id !== MAIN_CODEX_ACCOUNT_ID && !configuredPoolAccount(runtimeConfig, body.id)) {
293
+ return jsonResponse({ error: "Codex account not found" }, 404);
294
+ }
295
+ const rollback = captureConfigTopLevelRollback(runtimeConfig, ["codexAccountAutoSwitchThresholds"]);
296
+ try {
297
+ // Inheritance resets delete children in place; keep the previous map intact for rollback.
298
+ if (runtimeConfig.codexAccountAutoSwitchThresholds) {
299
+ runtimeConfig.codexAccountAutoSwitchThresholds = { ...runtimeConfig.codexAccountAutoSwitchThresholds };
300
+ }
301
+ setCodexAccountAutoSwitchThresholdOverride(runtimeConfig, body.id, threshold);
302
+ saveRuntimeConfig(config, runtimeConfig);
303
+ } catch (error) {
304
+ rollback();
305
+ throw error;
306
+ }
307
+ return jsonResponse({
308
+ ok: true,
309
+ id: body.id,
310
+ autoSwitchThresholdOverride: threshold,
311
+ autoSwitchThreshold: getEffectiveCodexAutoSwitchThreshold(runtimeConfig, body.id),
312
+ });
313
+ }
277
314
  if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 100) {
278
315
  return jsonResponse({ error: "Threshold must be an integer 0-100" }, 400);
279
316
  }
280
- const runtimeConfig = getRuntimeConfig(config);
281
317
  runtimeConfig.autoSwitchThreshold = body.threshold;
282
318
  saveRuntimeConfig(config, runtimeConfig);
283
319
  return jsonResponse({ ok: true });