@iislee/opencodex 2.31.0 → 2.35.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 (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. package/gui/dist/assets/index-DMiI18Kv.css +0 -1
@@ -5,6 +5,7 @@ import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nati
5
5
  import {
6
6
  DEFAULT_SUBAGENT_MODELS,
7
7
  codexAutoStartEnabled,
8
+ deleteConfigTopLevelKey,
8
9
  hasOwnProvider,
9
10
  isValidProviderName,
10
11
  multiAgentGuidanceEnabled,
@@ -298,6 +299,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
298
299
  streamMode: config.streamMode ?? "auto",
299
300
  appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256,
300
301
  codexAccountPickerEnabled: codexAccountPickerEnabled(config),
302
+ // Absent means hidden, so the GUI renders the switch without having to know that
303
+ // `undefined` and `false` mean the same thing.
304
+ showCodexSparkQuota: config.showCodexSparkQuota === true,
305
+ // Absent means the historical auto-open, so the GUI can render the toggle
306
+ // without having to know that `undefined` and `true` mean the same thing.
307
+ oauthOpenBrowser: config.oauthOpenBrowser !== false,
301
308
  startupHealth: await readStartupHealth(config),
302
309
  codexRuntime: {
303
310
  path: displayCodexRuntimePath(resolved.runtime.command),
@@ -382,16 +389,23 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
382
389
  streamMode?: unknown;
383
390
  appOwnedMemoryBudgetMb?: unknown;
384
391
  codexAccountPickerEnabled?: unknown;
392
+ oauthOpenBrowser?: unknown;
393
+ showCodexSparkQuota?: unknown;
385
394
  };
386
395
  if (body.codexAutoStart === undefined
387
396
  && body.streamMode === undefined
388
397
  && body.appOwnedMemoryBudgetMb === undefined
389
- && body.codexAccountPickerEnabled === undefined) {
390
- return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, or codexAccountPickerEnabled" }, 400);
398
+ && body.codexAccountPickerEnabled === undefined
399
+ && body.oauthOpenBrowser === undefined
400
+ && body.showCodexSparkQuota === undefined) {
401
+ return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, oauthOpenBrowser, or showCodexSparkQuota" }, 400);
391
402
  }
392
403
  if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
393
404
  return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
394
405
  }
406
+ if (body.oauthOpenBrowser !== undefined && typeof body.oauthOpenBrowser !== "boolean") {
407
+ return jsonResponse({ error: "oauthOpenBrowser boolean is required" }, 400);
408
+ }
395
409
  if (body.streamMode !== undefined && !isStreamMode(body.streamMode)) {
396
410
  return jsonResponse({ error: "streamMode must be auto, legacy-tee, or eager-relay" }, 400);
397
411
  }
@@ -399,6 +413,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
399
413
  && typeof body.codexAccountPickerEnabled !== "boolean") {
400
414
  return jsonResponse({ error: "codexAccountPickerEnabled boolean is required" }, 400);
401
415
  }
416
+ if (body.showCodexSparkQuota !== undefined && typeof body.showCodexSparkQuota !== "boolean") {
417
+ return jsonResponse({ error: "showCodexSparkQuota boolean is required" }, 400);
418
+ }
402
419
  if (body.appOwnedMemoryBudgetMb !== undefined && (
403
420
  typeof body.appOwnedMemoryBudgetMb !== "number"
404
421
  || !Number.isInteger(body.appOwnedMemoryBudgetMb)
@@ -418,6 +435,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
418
435
  hasCodexAccountNamespaces: Object.hasOwn(config, "codexAccountNamespaces"),
419
436
  codexAccountPickerEnabled: config.codexAccountPickerEnabled,
420
437
  hasCodexAccountPickerEnabled: Object.hasOwn(config, "codexAccountPickerEnabled"),
438
+ oauthOpenBrowser: config.oauthOpenBrowser,
439
+ hasOauthOpenBrowser: Object.hasOwn(config, "oauthOpenBrowser"),
440
+ showCodexSparkQuota: config.showCodexSparkQuota,
441
+ hasShowCodexSparkQuota: Object.hasOwn(config, "showCodexSparkQuota"),
421
442
  };
422
443
  const pickerWasEnabled = codexAccountPickerEnabled(config);
423
444
  let pickerIsEnabled = pickerWasEnabled;
@@ -427,7 +448,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
427
448
  }
428
449
  if (body.streamMode !== undefined) {
429
450
  if (body.streamMode === "auto") {
430
- delete config.streamMode;
451
+ deleteConfigTopLevelKey(config, "streamMode");
431
452
  } else {
432
453
  config.streamMode = body.streamMode as "legacy-tee" | "eager-relay";
433
454
  }
@@ -441,22 +462,34 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
441
462
  } else if (body.codexAccountPickerEnabled === false) {
442
463
  config.codexAccountPickerEnabled = false;
443
464
  }
465
+ if (typeof body.oauthOpenBrowser === "boolean") {
466
+ config.oauthOpenBrowser = body.oauthOpenBrowser;
467
+ }
468
+ if (typeof body.showCodexSparkQuota === "boolean") {
469
+ config.showCodexSparkQuota = body.showCodexSparkQuota;
470
+ }
444
471
  pickerIsEnabled = codexAccountPickerEnabled(config);
445
472
  (deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config);
446
473
  } catch (error) {
447
474
  if (previousSettings.hasCodexAutoStart) config.codexAutoStart = previousSettings.codexAutoStart;
448
- else delete config.codexAutoStart;
475
+ else deleteConfigTopLevelKey(config, "codexAutoStart");
449
476
  if (previousSettings.hasStreamMode) config.streamMode = previousSettings.streamMode;
450
- else delete config.streamMode;
477
+ else deleteConfigTopLevelKey(config, "streamMode");
451
478
  if (previousSettings.hasAppOwnedMemoryBudgetMb) {
452
479
  config.appOwnedMemoryBudgetMb = previousSettings.appOwnedMemoryBudgetMb;
453
- } else delete config.appOwnedMemoryBudgetMb;
480
+ } else deleteConfigTopLevelKey(config, "appOwnedMemoryBudgetMb");
454
481
  if (previousSettings.hasCodexAccountNamespaces) {
455
482
  config.codexAccountNamespaces = previousSettings.codexAccountNamespaces;
456
- } else delete config.codexAccountNamespaces;
483
+ } else deleteConfigTopLevelKey(config, "codexAccountNamespaces");
457
484
  if (previousSettings.hasCodexAccountPickerEnabled) {
458
485
  config.codexAccountPickerEnabled = previousSettings.codexAccountPickerEnabled;
459
- } else delete config.codexAccountPickerEnabled;
486
+ } else deleteConfigTopLevelKey(config, "codexAccountPickerEnabled");
487
+ if (previousSettings.hasOauthOpenBrowser) {
488
+ config.oauthOpenBrowser = previousSettings.oauthOpenBrowser;
489
+ } else deleteConfigTopLevelKey(config, "oauthOpenBrowser");
490
+ if (previousSettings.hasShowCodexSparkQuota) {
491
+ config.showCodexSparkQuota = previousSettings.showCodexSparkQuota;
492
+ } else deleteConfigTopLevelKey(config, "showCodexSparkQuota");
460
493
  throw error;
461
494
  }
462
495
  if (typeof body.appOwnedMemoryBudgetMb === "number") {
@@ -476,7 +509,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
476
509
  streamMode: config.streamMode ?? "auto",
477
510
  appOwnedMemoryBudgetMb: config.appOwnedMemoryBudgetMb ?? 256,
478
511
  codexAccountPickerEnabled: pickerIsEnabled,
512
+ oauthOpenBrowser: config.oauthOpenBrowser !== false,
479
513
  catalogRefreshPending,
514
+ showCodexSparkQuota: config.showCodexSparkQuota === true,
480
515
  startupHealth: await readStartupHealth(config),
481
516
  });
482
517
  }
@@ -490,7 +525,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
490
525
  if (url.pathname === "/api/sync" && req.method === "POST") {
491
526
  const { syncModelsToCodex } = await import("../../codex/sync");
492
527
  const { attachStaleAppServerHint } = await import("../../codex/app-server-processes");
493
- const { readRuntimePort, loadConfig } = await import("../../config");
528
+ const [{ readRuntimePort }, { loadConfig }] = await Promise.all([
529
+ import("../../config/process-state"),
530
+ import("../../config"),
531
+ ]);
494
532
  // Never use the server-captured startup object for a durable integration
495
533
  // decision. A toggle may have persisted while this process was gathering.
496
534
  const runtime = readRuntimePort(process.pid);
@@ -662,9 +700,18 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
662
700
 
663
701
  if (body.webSearch) {
664
702
  const pairTouched = body.webSearch.model !== undefined || body.webSearch.backend !== undefined;
665
- const effectiveBackend = body.webSearch.backend === "anthropic"
666
- ? "anthropic"
667
- : body.webSearch.backend === "openai" || body.webSearch.backend === null
703
+ // Validate against the backend the caller SUBMITTED, across the whole
704
+ // union — not just openai/anthropic (#2457). The union check above has
705
+ // already refused unknown literals, so a surviving string is a member;
706
+ // Array.includes does not narrow, hence the cast. Falling back to the
707
+ // stored backend for xai/gemini/exa both rejected legal pairs and
708
+ // accepted illegal ones: a submitted gemini was checked against a stored
709
+ // openai. null means "unset the backend", and unset resolves to openai.
710
+ const submittedBackend = body.webSearch.backend;
711
+ const effectiveBackend = typeof submittedBackend === "string"
712
+ && WEB_SEARCH_BACKENDS_UNION.includes(submittedBackend as never)
713
+ ? submittedBackend as typeof WEB_SEARCH_BACKENDS_UNION[number]
714
+ : submittedBackend === null
668
715
  ? "openai"
669
716
  : config.webSearchSidecar?.backend ?? "openai";
670
717
  const effectiveModel = typeof body.webSearch.model === "string"
@@ -6,9 +6,10 @@ import type { StartupHealth } from "../../codex/autostart-health";
6
6
  import type { StartupInstallAction } from "../startup-action-control";
7
7
  import type { ManagementPrincipal } from "../management-auth";
8
8
  import type { CatalogModel } from "../../codex/catalog";
9
+ import type { Paths as CodexPromptPaths } from "../../codex/prompt-layers";
9
10
  import type { injectGrokConfig } from "../../grok/inject";
10
11
  import type { removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
11
- import type { RuntimePortState } from "../../config";
12
+ import type { RuntimePortState } from "../../config/process-state";
12
13
  import type { CatalogDisposition, ConvergeCodex } from "../../codex/convergence-types";
13
14
  import type {
14
15
  performCodexRestart,
@@ -87,6 +88,14 @@ export interface ManagementApiDeps {
87
88
  * state inside their temporary Codex home.
88
89
  */
89
90
  codexLogGuardMaintenanceDeps?: CodexLogGuardMaintenanceDeps;
91
+ /**
92
+ * Prompt-layer path seam. Production leaves this unset and
93
+ * `src/codex/prompt-layers.ts` resolves the real CODEX_HOME. A route test
94
+ * that could not inject paths would read AND WRITE the developer's live
95
+ * ~/.codex/config.toml — the same class of incident
96
+ * `saveConfigPreservingClaudeCode` above exists to prevent.
97
+ */
98
+ codexPromptPaths?: CodexPromptPaths;
90
99
  }
91
100
 
92
101
 
@@ -32,8 +32,6 @@ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
32
32
  import { clearThreadAccountMap } from "../../codex/routing";
33
33
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
34
34
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
35
- import { resolveCodexHomeDir } from "../../codex/home";
36
- import { scanStorage } from "../../storage/scanner";
37
35
  import { executeArchivedCleanup, listTrashEntries, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode, type RestoreErrorCode } from "../../storage/cleanup";
38
36
  import { runArchivedCleanupJob } from "../../storage/cleanup-job";
39
37
  import { getRestoreTrashTestStreamResponse, runRestoreTrashEntryJob } from "../../storage/restore-job";
@@ -55,7 +53,7 @@ import {
55
53
  type PersistedUsageEntry,
56
54
  } from "../../usage/log";
57
55
  import { getUsageDebugLogEntries } from "../../usage/debug";
58
- import { parseRange, parseUsageSurface, rangeWindow, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary";
56
+ import { USAGE_RANGES, USAGE_SURFACES, parseRange, parseUsageSurface, projectUsageSummary, rangeWindow, summarizeUsage, type UsageRange, type UsageSummary, type UsageSurface } from "../../usage/summary";
59
57
  import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
60
58
  import { getProviderRegistryEntry } from "../../providers/registry";
61
59
  import { getDebugLogEntries } from "../../lib/debug-log-buffer";
@@ -316,6 +314,17 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
316
314
  if (url.pathname === "/api/usage" && req.method === "GET") {
317
315
  const range = parseRange(url.searchParams.get("range"));
318
316
  const surface = parseUsageSurface(url.searchParams.get("surface"));
317
+ // Applied to the OUTGOING payload only. A filtered summary must never reach
318
+ // the cache or the warm loop below: the key is `range:surface`, so a
319
+ // filtered entry stored under it would be served to the next unfiltered
320
+ // caller, dashboard included.
321
+ const filter = {
322
+ provider: url.searchParams.get("provider"),
323
+ model: url.searchParams.get("model"),
324
+ };
325
+ const project = <T extends UsageSummary>(summary: T, entries?: PersistedUsageEntry[]) =>
326
+ projectUsageSummary(summary, filter, entries);
327
+ const filterRequested = Boolean(filter.provider ?? filter.model);
319
328
  const now = Date.now();
320
329
  try {
321
330
  const cacheKey = `${range}:${surface}`;
@@ -325,6 +334,12 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
325
334
  const observedSize = observed?.size ?? 0;
326
335
  const cached = getUsageSummaryCacheEntry(cacheKey);
327
336
  if (cached
337
+ // A filtered response is re-summarised from entries, which a cached
338
+ // summary does not carry. Serving it from cache would mean projecting
339
+ // over collapsed breakdown rows again — the exact defect the
340
+ // re-summarisation replaced. The unfiltered cache stays warm either
341
+ // way; only the filtered caller pays for the read.
342
+ && !filterRequested
328
343
  && cached.identityKey === identityKey
329
344
  && cached.maxReadBytes === effectiveReadLimit
330
345
  && cached.overlayVersion === userCostOverlayVersion()
@@ -333,7 +348,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
333
348
  && observedSize >= cached.lastSeenSize) {
334
349
  return jsonResponse(refreshedUsageSummary(cached.summary, range, now));
335
350
  }
336
- if (cached) discardUsageSummaryCacheEntry(cacheKey);
351
+ if (cached && !filterRequested) discardUsageSummaryCacheEntry(cacheKey);
337
352
  // Capture the overlay version BEFORE reading/computing: the cache entry
338
353
  // must be stamped with the version the summary was priced under. Reading
339
354
  // it again at stamp time could cache an old-price summary as current,
@@ -357,14 +372,18 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
357
372
  // summary may mix old and new prices. Serve it uncached: the next
358
373
  // request recomputes against the settled overlay instead of caching a
359
374
  // mixed-price entry under either version.
360
- return jsonResponse(summary);
375
+ return jsonResponse(project(summary, snapshot.entries));
361
376
  }
362
377
  const freshUntil = now + 60_000;
363
378
  const snapshotIdentity = `${usageLogIdentityKey(snapshot.revision)}\0${effectiveReadLimit}`;
364
379
  const revisionKey = `${usageLogRevisionKey(snapshot.revision)}\0${effectiveReadLimit}`;
365
380
  const lastSeenSize = snapshot.revision?.size ?? 0;
366
- const ranges: UsageRange[] = ["7d", "30d", "all"];
367
- const surfaces: UsageSurface[] = ["all", "codex", "claude", "grok"];
381
+ // Derived from the canonical constants rather than re-listed: a subset
382
+ // literal type-checks perfectly happily, so a range added to the union
383
+ // and forgotten here would never be warmed and never invalidated
384
+ // alongside its siblings.
385
+ const ranges: readonly UsageRange[] = USAGE_RANGES;
386
+ const surfaces: readonly UsageSurface[] = USAGE_SURFACES;
368
387
  for (const nextRange of ranges) {
369
388
  for (const nextSurface of surfaces) {
370
389
  const nextSummary = nextRange === range && nextSurface === surface ? summary : {
@@ -398,7 +417,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
398
417
  effectiveReadLimit,
399
418
  now,
400
419
  );
401
- return jsonResponse(summary);
420
+ return jsonResponse(project(summary, snapshot.entries));
402
421
  } catch {
403
422
  return jsonResponse({
404
423
  range,
@@ -450,8 +469,9 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
450
469
  if (url.pathname === "/api/usage/ingest" && req.method === "POST") {
451
470
  let body: unknown;
452
471
  try {
453
- body = await req.json();
454
- } catch {
472
+ body = await readManagementJsonBody(req);
473
+ } catch (error) {
474
+ rethrowManagementBodyTooLarge(error);
455
475
  return jsonResponse({ error: "invalid JSON body" }, 400);
456
476
  }
457
477
  const rawEntries = extractIngestRawEntries(body);
@@ -512,20 +532,6 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
512
532
  }, status);
513
533
  }
514
534
 
515
- if (url.pathname === "/api/storage" && req.method === "GET") {
516
- try {
517
- return jsonResponse(scanStorage());
518
- } catch {
519
- return jsonResponse({
520
- codexHome: resolveCodexHomeDir(),
521
- generatedAt: Date.now(),
522
- total: { bytes: 0, fileCount: 0 },
523
- buckets: [],
524
- error: "scan_failed",
525
- });
526
- }
527
- }
528
-
529
535
  if (url.pathname === "/api/storage/cleanup/preview" && req.method === "POST") {
530
536
  let body: { percent?: unknown };
531
537
  try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400); }
@@ -99,6 +99,8 @@ import { deriveProviderPresets } from "../../providers/derive";
99
99
  import { providerCodexAccountMode } from "../../providers/registry";
100
100
  import { encodedModelIdCollides, routedSlug, slugEquals } from "../../providers/slug-codec";
101
101
  import { knownModelIdsForProvider } from "../../router";
102
+ import { effectiveModelAliases, MODEL_ALIAS_PATTERN } from "../../providers/default-aliases";
103
+ import { comboPublicModelId } from "../../combos/types";
102
104
  import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos";
103
105
  import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
104
106
  import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
@@ -149,6 +151,12 @@ import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, C
149
151
  import type { ManagementContext } from "./context";
150
152
  import { listManagementModelRows, loadExportModels } from "./model-rows";
151
153
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
154
+ import {
155
+ hasModelPreset,
156
+ markModelPresetDiverged,
157
+ materializeModelPreset,
158
+ modelPresetFor,
159
+ } from "../../providers/model-presets";
152
160
 
153
161
  /**
154
162
  * Counts read back off the SERIALIZED document rather than recomputed from the input rows.
@@ -171,6 +179,158 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
171
179
  // ~/.opencodex/config.json with the `existing-uuid` test fixture.
172
180
  const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
173
181
 
182
+ if (url.pathname === "/api/model-discovery" && req.method === "GET") {
183
+ const providers = Object.fromEntries(Object.entries(config.providers).map(([name, provider]) => [
184
+ name, provider.newModelPolicy ?? "inherit",
185
+ ]));
186
+ const recentArrivals = Object.fromEntries(Object.entries(config.modelDiscovery?.recentArrivals ?? {}).map(([name, rows]) => [
187
+ name,
188
+ rows.map(row => ({
189
+ ...row,
190
+ state: (config.disabledModels ?? []).some(slug => slugEquals(slug, name, row.id))
191
+ ? "auto-disabled" : "enabled",
192
+ })),
193
+ ]));
194
+ const baselineCounts = Object.fromEntries(Object.entries(config.modelDiscovery?.knownModels ?? {}).map(([name, baseline]) => [
195
+ name, baseline.ids.length,
196
+ ]));
197
+ return jsonResponse({
198
+ policy: config.modelDiscovery?.newModelPolicy ?? "on", providers, recentArrivals, baselineCounts,
199
+ });
200
+ }
201
+
202
+ if (url.pathname === "/api/model-discovery" && req.method === "PUT") {
203
+ let body: { policy?: unknown; provider?: unknown };
204
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
205
+ if (body.policy !== "on" && body.policy !== "off") return jsonResponse({ error: "policy must be on or off" }, 400);
206
+ const provider = typeof body.provider === "string" && body.provider.trim() ? body.provider.trim() : null;
207
+ let baselineBootstrapped = false;
208
+ if (provider) {
209
+ if (!hasOwnProvider(config.providers, provider)) return jsonResponse({ error: "unknown provider" }, 404);
210
+ config.providers[provider].newModelPolicy = body.policy;
211
+ } else {
212
+ const wasAbsent = config.modelDiscovery?.newModelPolicy === undefined;
213
+ config.modelDiscovery ??= {};
214
+ config.modelDiscovery.newModelPolicy = body.policy;
215
+ if (body.policy === "off" && wasAbsent) {
216
+ const models = await fetchAllModels(config);
217
+ const known = config.modelDiscovery.knownModels ??= {};
218
+ const at = new Date().toISOString();
219
+ for (const name of Object.keys(config.providers)) {
220
+ known[name] ??= { ids: [...new Set(models.filter(m => m.provider === name).map(m => m.id))].sort(), removed: [], updatedAt: at };
221
+ }
222
+ baselineBootstrapped = true;
223
+ }
224
+ }
225
+ persistConfig(config);
226
+ const catalogRefresh = await convergeCodexCatalog();
227
+ return jsonResponse({ ok: true, policy: body.policy, provider, ...(baselineBootstrapped ? { baselineBootstrapped } : {}), catalogRefresh });
228
+ }
229
+
230
+ if (url.pathname === "/api/model-discovery/acknowledge" && req.method === "POST") {
231
+ let body: { provider?: unknown; ids?: unknown };
232
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
233
+ const provider = typeof body.provider === "string" ? body.provider.trim() : "";
234
+ if (!provider || !Array.isArray(body.ids) || body.ids.some(id => typeof id !== "string")) {
235
+ return jsonResponse({ error: "provider and string ids are required" }, 400);
236
+ }
237
+ const acknowledged = new Set(body.ids as string[]);
238
+ const recent = config.modelDiscovery?.recentArrivals;
239
+ if (recent?.[provider]) recent[provider] = recent[provider].filter(row => !acknowledged.has(row.id));
240
+ persistConfig(config);
241
+ const catalogRefresh = await convergeCodexCatalog();
242
+ return jsonResponse({ ok: true, provider, acknowledged: [...acknowledged], catalogRefresh });
243
+ }
244
+
245
+ if (url.pathname === "/api/aliases" && req.method === "GET") {
246
+ const providers: Record<string, string> = {};
247
+ const models: Record<string, Record<string, { alias: string; source: "user" | "builtin"; stale?: boolean }>> = {};
248
+ for (const [name, provider] of Object.entries(config.providers)) {
249
+ if (provider.alias) providers[name] = provider.alias;
250
+ const known = knownModelIdsForProvider(name, provider, config);
251
+ const knownSet = new Set(known);
252
+ const rows: Record<string, { alias: string; source: "user" | "builtin"; stale?: boolean }> = {};
253
+ for (const [id, value] of effectiveModelAliases(config, provider, new Set([...known, ...Object.keys(provider.modelAliases ?? {})]))) {
254
+ rows[id] = { ...value, ...(!knownSet.has(id) ? { stale: true } : {}) };
255
+ }
256
+ if (Object.keys(rows).length) models[name] = rows;
257
+ }
258
+ return jsonResponse({ providers, models, defaults: {
259
+ global: config.defaultModelAliases ?? false,
260
+ providers: Object.fromEntries(Object.entries(config.providers).filter(([, p]) => p.defaultAliases !== undefined).map(([n, p]) => [n, p.defaultAliases])),
261
+ } });
262
+ }
263
+
264
+ const providerAliasMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/alias$/);
265
+ if (providerAliasMatch && req.method === "PUT") {
266
+ const name = decodeURIComponent(providerAliasMatch[1]!);
267
+ // `keys` is not a provider name: `/api/providers/keys/alias` is the API-KEY POOL's rename
268
+ // endpoint (oauth-account-routes.ts), and model routes are dispatched BEFORE it. Without
269
+ // this guard the alias route matched `name = "keys"`, found no such provider, and returned
270
+ // 404 for every key-pool rename.
271
+ if (name === "keys") return null;
272
+ const provider = config.providers[name];
273
+ if (!provider) return jsonResponse({ error: `provider '${name}' not found` }, 404, req, config);
274
+ let raw: unknown;
275
+ try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
276
+ if (!isPlainRecord(raw) || (raw.alias !== null && typeof raw.alias !== "string")) return jsonResponse({ error: "alias must be a string or null" }, 400, req, config);
277
+ const alias = typeof raw.alias === "string" ? raw.alias.trim() : null;
278
+ if (alias && !isValidProviderName(alias)) return jsonResponse({ error: "invalid provider alias" }, 400, req, config);
279
+ const lower = alias?.toLowerCase();
280
+ const collision = lower && Object.entries(config.providers).find(([other, p]) =>
281
+ other !== name && (other.toLowerCase() === lower || p.alias?.toLowerCase() === lower));
282
+ const comboCollision = lower && Object.entries(config.combos ?? {}).find(([, combo]) => comboPublicModelId("", combo).toLowerCase() === lower);
283
+ const accountCollision = lower && Object.keys(config.codexAccountNamespaces ?? {}).find(value => value.toLowerCase() === lower);
284
+ if (collision || comboCollision || accountCollision) return jsonResponse({ error: `alias conflicts with '${collision?.[0] ?? comboCollision?.[0] ?? accountCollision}'` }, 409, req, config);
285
+ if (alias) provider.alias = alias; else delete provider.alias;
286
+ persistConfig(config);
287
+ const catalogRefresh = await convergeCodexCatalog();
288
+ return jsonResponse({ ok: true, provider: name, alias, catalogRefresh });
289
+ }
290
+
291
+ const modelAliasMatch = url.pathname.match(/^\/api\/providers\/([^/]+)\/model-aliases$/);
292
+ if (modelAliasMatch && req.method === "PUT") {
293
+ const name = decodeURIComponent(modelAliasMatch[1]!);
294
+ if (name === "keys") return null;
295
+ const provider = config.providers[name];
296
+ if (!provider) return jsonResponse({ error: `provider '${name}' not found` }, 404, req, config);
297
+ let raw: unknown;
298
+ try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
299
+ if (!isPlainRecord(raw) || (raw.set !== undefined && !isPlainRecord(raw.set)) || (raw.remove !== undefined && !Array.isArray(raw.remove))) return jsonResponse({ error: "invalid model alias update" }, 400, req, config);
300
+ const next = { ...(provider.modelAliases ?? {}) };
301
+ for (const id of (raw.remove ?? []) as unknown[]) if (typeof id === "string") delete next[id];
302
+ const conflicts: Array<{ alias: string; heldBy: string }> = [];
303
+ const known = knownModelIdsForProvider(name, provider, config);
304
+ for (const [id, value] of Object.entries((raw.set ?? {}) as Record<string, unknown>)) {
305
+ if (typeof value !== "string" || !MODEL_ALIAS_PATTERN.test(value)) return jsonResponse({ error: `invalid model alias for '${id}'` }, 400, req, config);
306
+ const lower = value.toLowerCase();
307
+ const heldBy = Object.entries(next).find(([other, alias]) => other !== id && alias.toLowerCase() === lower)?.[0]
308
+ ?? known.find(native => native.toLowerCase() === lower)
309
+ ?? Object.entries(config.combos ?? {}).find(([, combo]) => comboPublicModelId("", combo).toLowerCase() === lower)?.[0];
310
+ if (heldBy || /^(?:gpt-|o1-|o3-|o4-|codex-)/i.test(value)) conflicts.push({ alias: value, heldBy: heldBy ?? "native OpenAI family" });
311
+ else next[id] = value;
312
+ }
313
+ if (conflicts.length) return jsonResponse({ error: "model alias collision", conflicts }, 409, req, config);
314
+ provider.modelAliases = next;
315
+ persistConfig(config);
316
+ const catalogRefresh = await convergeCodexCatalog();
317
+ return jsonResponse({ ok: true, aliases: next, catalogRefresh });
318
+ }
319
+
320
+ if (url.pathname === "/api/default-aliases" && req.method === "PUT") {
321
+ let raw: unknown;
322
+ try { raw = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
323
+ if (!isPlainRecord(raw) || typeof raw.enabled !== "boolean" || (raw.provider !== undefined && typeof raw.provider !== "string")) return jsonResponse({ error: "enabled must be boolean" }, 400, req, config);
324
+ if (typeof raw.provider === "string") {
325
+ const provider = config.providers[raw.provider];
326
+ if (!provider) return jsonResponse({ error: `provider '${raw.provider}' not found` }, 404, req, config);
327
+ provider.defaultAliases = raw.enabled;
328
+ } else config.defaultModelAliases = raw.enabled;
329
+ persistConfig(config);
330
+ const catalogRefresh = await convergeCodexCatalog();
331
+ return jsonResponse({ ok: true, catalogRefresh });
332
+ }
333
+
174
334
  if (url.pathname === "/api/catalog" && req.method === "GET") {
175
335
  const { readCatalog, readCodexCatalogPath } = await import("../../codex/catalog");
176
336
  const catalog = readCatalog(readCodexCatalogPath());
@@ -370,6 +530,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
370
530
  providerConfig.selectedModels = [...new Set([...providerConfig.selectedModels, ...additions])];
371
531
  }
372
532
  disabled = disabled.filter(stored => !targets.some(target => matchesTarget(stored, target)));
533
+ const arrivals = config.modelDiscovery?.recentArrivals?.[provider];
534
+ if (arrivals) config.modelDiscovery!.recentArrivals![provider] = arrivals.filter(row => (
535
+ !targets.some(target => !target.native && target.id === row.id)
536
+ ));
373
537
  }
374
538
  } else {
375
539
  for (const target of targets) {
@@ -394,8 +558,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
394
558
  }
395
559
 
396
560
  if (url.pathname === "/api/custom-models" && req.method === "POST") {
397
- let body: { provider?: unknown; modelId?: unknown; displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown; reasoningEfforts?: unknown; defaultReasoningEffort?: unknown };
398
- try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
561
+ let parsedBody: unknown;
562
+ try { parsedBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
563
+ if (!isPlainRecord(parsedBody)) return jsonResponse({ error: "invalid JSON body" }, 400);
564
+ const body = parsedBody;
399
565
  const provider = typeof body.provider === "string" ? body.provider.trim() : "";
400
566
  const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
401
567
  if (!provider || !modelId) return jsonResponse({ error: "provider and modelId are required" }, 400);
@@ -441,8 +607,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
441
607
  if (customPutMatch && req.method === "PUT") {
442
608
  let id: string;
443
609
  try { id = decodeURIComponent(customPutMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
444
- let body: { displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown; modelId?: unknown; reasoningEfforts?: unknown; defaultReasoningEffort?: unknown };
445
- try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
610
+ let parsedBody: unknown;
611
+ try { parsedBody = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
612
+ if (!isPlainRecord(parsedBody)) return jsonResponse({ error: "invalid JSON body" }, 400);
613
+ const body = parsedBody;
446
614
  const list = config.customModels ?? [];
447
615
  const idx = list.findIndex(cm => cm.id === id);
448
616
  if (idx === -1) return jsonResponse({ error: "not found" }, 404);
@@ -562,6 +730,104 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
562
730
  const refreshing = catalogGatherNeedsLiveRefresh(config);
563
731
  return jsonResponse({ selected, available, liveModelCounts, refreshing });
564
732
  }
733
+ if (url.pathname === "/api/model-presets" && req.method === "GET") {
734
+ // Preview without applying: rules evaluated against the CURRENT catalog, so the count the
735
+ // user sees is the count they would get.
736
+ const models = await fetchAllModels(config);
737
+ const byProvider = new Map<string, string[]>();
738
+ for (const m of models) {
739
+ const ids = byProvider.get(m.provider) ?? [];
740
+ ids.push(m.id);
741
+ byProvider.set(m.provider, ids);
742
+ }
743
+ const providers: Record<string, unknown> = {};
744
+ for (const [name, prov] of Object.entries(config.providers)) {
745
+ const preset = modelPresetFor(name);
746
+ if (!preset) continue;
747
+ const catalogIds = byProvider.get(name) ?? [];
748
+ const presetIds = materializeModelPreset(name, catalogIds);
749
+ providers[name] = {
750
+ mode: prov.modelPreset?.mode ?? "all",
751
+ ...(prov.modelPreset?.appliedVersion !== undefined
752
+ ? { appliedVersion: prov.modelPreset.appliedVersion }
753
+ : {}),
754
+ availableVersion: preset.version,
755
+ presetIds,
756
+ presetCount: presetIds.length,
757
+ totalCount: catalogIds.length,
758
+ ...(prov.modelPreset?.fallback ? { fallback: prov.modelPreset.fallback } : {}),
759
+ };
760
+ }
761
+ return jsonResponse({ providers });
762
+ }
763
+ if (url.pathname === "/api/model-presets" && req.method === "PUT") {
764
+ let body: { provider?: unknown; mode?: unknown };
765
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
766
+ const provider = typeof body.provider === "string" ? body.provider : "";
767
+ if (!provider || !hasOwnProvider(config.providers, provider)) {
768
+ return jsonResponse({ error: "unknown provider" }, provider ? 404 : 400);
769
+ }
770
+ const mode = body.mode;
771
+ if (mode !== "preset" && mode !== "all" && mode !== "custom") {
772
+ return jsonResponse({ error: "mode must be preset, all, or custom" }, 400);
773
+ }
774
+ const target = config.providers[provider];
775
+ if (mode === "all") {
776
+ // Same effect as today's empty-list PUT: no allowlist, no marker to reconcile.
777
+ delete target.selectedModels;
778
+ delete target.modelPreset;
779
+ persistConfig(config);
780
+ return jsonResponse({ ok: true, provider, mode, selected: [], catalogRefresh: await convergeCodexCatalog() });
781
+ }
782
+ if (mode === "custom") {
783
+ // Keep whatever is selected; only the marker changes, so a user can pin their edits
784
+ // without the proxy re-materializing over them.
785
+ target.modelPreset = { ...(target.modelPreset ?? {}), mode: "custom" };
786
+ persistConfig(config);
787
+ return jsonResponse({ ok: true, provider, mode, selected: [...(target.selectedModels ?? [])] });
788
+ }
789
+ if (!hasModelPreset(provider)) {
790
+ return jsonResponse({ error: `no model preset is shipped for provider '${provider}'` }, 400);
791
+ }
792
+ const models = await fetchAllModels(config);
793
+ const catalogIds = models.filter(m => m.provider === provider).map(m => m.id);
794
+ const presetIds = materializeModelPreset(provider, catalogIds);
795
+ const preset = modelPresetFor(provider)!;
796
+ if (presetIds.length === 0) {
797
+ // NEVER write an empty allowlist from a preset: empty means ALL, so it would silently
798
+ // un-curate instead of curating. Keep the previous selection and record the fallback so
799
+ // the next convergence can retry.
800
+ target.modelPreset = {
801
+ mode: "all",
802
+ appliedVersion: preset.version,
803
+ appliedAt: new Date().toISOString(),
804
+ fallback: "preset-empty",
805
+ };
806
+ persistConfig(config);
807
+ return jsonResponse({
808
+ ok: true,
809
+ provider,
810
+ mode: "all",
811
+ fallback: "preset-empty",
812
+ selected: [...(target.selectedModels ?? [])],
813
+ });
814
+ }
815
+ target.selectedModels = presetIds;
816
+ target.modelPreset = {
817
+ mode: "preset",
818
+ appliedVersion: preset.version,
819
+ appliedAt: new Date().toISOString(),
820
+ };
821
+ persistConfig(config);
822
+ return jsonResponse({
823
+ ok: true,
824
+ provider,
825
+ mode: "preset",
826
+ appliedVersion: preset.version,
827
+ selected: presetIds,
828
+ catalogRefresh: await convergeCodexCatalog(),
829
+ });
830
+ }
565
831
  if (url.pathname === "/api/selected-models" && req.method === "PUT") {
566
832
  let body: { provider?: unknown; models?: unknown };
567
833
  try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
@@ -575,6 +841,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
575
841
  // Empty list clears the allowlist (provider reverts to exposing all models).
576
842
  if (models.length > 0) config.providers[provider].selectedModels = models;
577
843
  else delete config.providers[provider].selectedModels;
844
+ // Divergence is detected at the WRITE path, not by diffing (#2465): a user edit while the
845
+ // provider is in preset mode makes the selection theirs, and the proxy must never
846
+ // re-materialize over it afterwards.
847
+ markModelPresetDiverged(config.providers[provider]);
578
848
  persistConfig(config);
579
849
  const catalogRefresh = await convergeCodexCatalog();
580
850
  return jsonResponse({ ok: true, provider, selected: models, catalogRefresh });