@yansigit/opencodex 2.33.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 (196) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/index-BjCaHxdz.js +112 -0
  3. package/gui/dist/assets/index-DLkXOXLC.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +141 -23
  8. package/src/adapters/cursor/call-id.ts +44 -0
  9. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  10. package/src/adapters/cursor/discovery.ts +60 -2
  11. package/src/adapters/cursor/effort-map.ts +79 -1
  12. package/src/adapters/cursor/envelope-echo.ts +162 -0
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +17 -1
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-fs.ts +13 -12
  17. package/src/adapters/cursor/native-exec-network.ts +3 -5
  18. package/src/adapters/cursor/native-exec-policy.ts +47 -0
  19. package/src/adapters/cursor/native-exec-shell.ts +116 -31
  20. package/src/adapters/cursor/native-exec.ts +38 -10
  21. package/src/adapters/cursor/protobuf-events.ts +28 -2
  22. package/src/adapters/cursor/protobuf-request.ts +93 -41
  23. package/src/adapters/cursor/request-builder.ts +39 -10
  24. package/src/adapters/cursor/tool-definitions.ts +27 -3
  25. package/src/adapters/cursor/tool-result-normalize.ts +51 -6
  26. package/src/adapters/cursor/types.ts +23 -4
  27. package/src/adapters/cursor.ts +170 -29
  28. package/src/adapters/google-aistudio-parser.ts +49 -0
  29. package/src/adapters/google-antigravity-replay.ts +105 -25
  30. package/src/adapters/google-antigravity-wire.ts +5 -0
  31. package/src/adapters/google-errors.ts +41 -12
  32. package/src/adapters/google-http.ts +12 -11
  33. package/src/adapters/google.ts +219 -36
  34. package/src/adapters/image.ts +1 -1
  35. package/src/adapters/kiro-constants.ts +15 -0
  36. package/src/adapters/kiro-tools.ts +43 -15
  37. package/src/adapters/kiro.ts +54 -9
  38. package/src/adapters/openai-chat.ts +286 -242
  39. package/src/adapters/openai-responses.ts +335 -24
  40. package/src/adapters/run-turn-queue.ts +36 -1
  41. package/src/adapters/tool-catalog-nudge.ts +2 -2
  42. package/src/adapters/xai-tool-schema.ts +436 -0
  43. package/src/bridge.ts +67 -26
  44. package/src/chat/inbound.ts +29 -1
  45. package/src/chat/outbound.ts +15 -7
  46. package/src/claude/agents-inject.ts +8 -1
  47. package/src/claude/outbound.ts +10 -8
  48. package/src/cli/account-api.ts +27 -7
  49. package/src/cli/account-extended.ts +10 -3
  50. package/src/cli/account.ts +29 -5
  51. package/src/cli/alias.ts +66 -0
  52. package/src/cli/claude.ts +26 -1
  53. package/src/cli/dispatch.ts +13 -1
  54. package/src/cli/help.ts +1 -0
  55. package/src/cli/index.ts +6 -1
  56. package/src/cli/init.ts +1 -0
  57. package/src/cli/models-runtime.ts +95 -0
  58. package/src/cli/models.ts +13 -7
  59. package/src/cli/provider-runtime.ts +16 -2
  60. package/src/cli/registry.ts +6 -1
  61. package/src/cli/telemetry-commands.ts +25 -0
  62. package/src/cli/v2.ts +34 -10
  63. package/src/codex/account-pause.ts +2 -1
  64. package/src/codex/account-priority.ts +3 -2
  65. package/src/codex/app-server-processes.ts +80 -6
  66. package/src/codex/auth-api.ts +48 -8
  67. package/src/codex/auth-context.ts +21 -18
  68. package/src/codex/catalog/aggregation.ts +6 -0
  69. package/src/codex/catalog/model-metadata.ts +13 -1
  70. package/src/codex/catalog/native-models.ts +5 -2
  71. package/src/codex/catalog/parsing.ts +16 -0
  72. package/src/codex/catalog/provider-fetch.ts +20 -3
  73. package/src/codex/catalog/sync.ts +127 -2
  74. package/src/codex/catalog.ts +1 -1
  75. package/src/codex/codex-write-lock.ts +3 -1
  76. package/src/codex/convergence-types.ts +1 -1
  77. package/src/codex/convergence.ts +22 -2
  78. package/src/codex/desired-state.ts +2 -2
  79. package/src/codex/desktop-app-restart.ts +18 -5
  80. package/src/codex/inject-coordination.ts +83 -0
  81. package/src/codex/inject.ts +14 -1
  82. package/src/codex/log-guard/inspect.ts +22 -4
  83. package/src/codex/model-entitlements.ts +9 -2
  84. package/src/codex/prompt-layers.ts +371 -25
  85. package/src/codex/prompt-text-probe.ts +238 -0
  86. package/src/codex/quota.ts +123 -18
  87. package/src/codex/routing.ts +9 -0
  88. package/src/codex/subagent-model-fallback.ts +198 -27
  89. package/src/codex/transition-state.ts +107 -8
  90. package/src/combos/types.ts +10 -0
  91. package/src/compatibility/openai-responses.ts +33 -1
  92. package/src/config/autonomous-remediation.ts +21 -0
  93. package/src/config/provider-validation.ts +14 -0
  94. package/src/config/rebase-provenance.ts +68 -0
  95. package/src/config.ts +191 -17
  96. package/src/generated/compatibility-version.json +279 -159
  97. package/src/generated/model-metadata.ts +3 -0
  98. package/src/images/loop.ts +5 -4
  99. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  100. package/src/lab/fabric/producer-child.ts +1 -1
  101. package/src/lib/config-ownership.ts +20 -0
  102. package/src/lib/errors.ts +11 -2
  103. package/src/lib/package-tree-integrity.ts +101 -0
  104. package/src/oauth/aistudio-credentials.ts +65 -0
  105. package/src/oauth/aistudio-native-daemon.ts +116 -0
  106. package/src/oauth/aistudio-session-sync.ts +95 -0
  107. package/src/oauth/generic-account-failover.ts +231 -0
  108. package/src/oauth/google-aistudio-auth.ts +98 -0
  109. package/src/oauth/index.ts +57 -5
  110. package/src/oauth/key-providers.ts +18 -1
  111. package/src/oauth/kiro.ts +45 -0
  112. package/src/oauth/login-cli.ts +65 -1
  113. package/src/oauth/types.ts +15 -0
  114. package/src/providers/codex-capacity.ts +5 -2
  115. package/src/providers/command-code-efforts.ts +38 -6
  116. package/src/providers/context-cap.ts +4 -3
  117. package/src/providers/default-aliases.ts +65 -0
  118. package/src/providers/derive.ts +29 -1
  119. package/src/providers/fastwire.ts +7 -1
  120. package/src/providers/model-presets.ts +119 -0
  121. package/src/providers/new-model-policy.ts +146 -0
  122. package/src/providers/provider-id-rewrite.ts +2 -1
  123. package/src/providers/quota.ts +157 -46
  124. package/src/providers/registry.ts +184 -71
  125. package/src/providers/slug-codec.ts +52 -0
  126. package/src/responses/code-mode-helper-compat.ts +50 -0
  127. package/src/responses/custom-tool-compat.ts +34 -10
  128. package/src/responses/parser.ts +4 -0
  129. package/src/responses/schema.ts +5 -1
  130. package/src/responses/thought-signature-replay.ts +17 -0
  131. package/src/router.ts +43 -2
  132. package/src/routing/account-pool/cooldown.ts +8 -0
  133. package/src/routing/account-pool/index.ts +1 -0
  134. package/src/routing/analytics.ts +1 -0
  135. package/src/routing/quota.ts +10 -0
  136. package/src/server/auth-cors.ts +24 -0
  137. package/src/server/chat-completions.ts +26 -16
  138. package/src/server/chat-native-sse.ts +3 -3
  139. package/src/server/chat-native.ts +30 -11
  140. package/src/server/claude-messages.ts +1 -1
  141. package/src/server/effort-policy.ts +16 -0
  142. package/src/server/index.ts +180 -14
  143. package/src/server/lifecycle.ts +52 -1
  144. package/src/server/management/agent-settings-routes.ts +31 -15
  145. package/src/server/management/codex-prompt-routes.ts +570 -0
  146. package/src/server/management/combo-routes.ts +2 -1
  147. package/src/server/management/config-routes.ts +27 -9
  148. package/src/server/management/context.ts +9 -0
  149. package/src/server/management/logs-usage-routes.ts +11 -5
  150. package/src/server/management/model-routes.ts +266 -0
  151. package/src/server/management/oauth-account-routes.ts +13 -3
  152. package/src/server/management/provider-routes.ts +137 -3
  153. package/src/server/management/routing-profile-routes.ts +2 -2
  154. package/src/server/management-api.ts +2 -0
  155. package/src/server/port-reclaim.ts +19 -1
  156. package/src/server/relay-eager.ts +147 -20
  157. package/src/server/relay.ts +251 -19
  158. package/src/server/request-log-conversation.ts +33 -0
  159. package/src/server/request-log.ts +48 -21
  160. package/src/server/responses/collaboration.ts +42 -5
  161. package/src/server/responses/combo-stream-preflight.ts +10 -3
  162. package/src/server/responses/core.ts +575 -140
  163. package/src/server/responses/empty-completion-guard.ts +35 -0
  164. package/src/server/responses/fetch-helpers.ts +14 -6
  165. package/src/server/responses/input-admission.ts +3 -1
  166. package/src/server/responses/passthrough-error.ts +33 -9
  167. package/src/server/responses/policy-fallback.ts +1 -1
  168. package/src/server/responses/responses-field-backfill.ts +105 -13
  169. package/src/server/responses/ws-upstream.ts +35 -5
  170. package/src/server/responses-custom-tool-repair.ts +52 -7
  171. package/src/server/responses-terminal-repair.ts +25 -4
  172. package/src/server/sse-frame-buffer.ts +31 -4
  173. package/src/server/ws-bridge.ts +14 -2
  174. package/src/smoke/fingerprint-cache.ts +133 -0
  175. package/src/smoke/live-scenarios.ts +33 -0
  176. package/src/smoke/runner.ts +119 -0
  177. package/src/telemetry/dispatcher.ts +44 -0
  178. package/src/telemetry/fingerprint.ts +24 -0
  179. package/src/telemetry/hook.ts +43 -0
  180. package/src/telemetry/ledger.ts +54 -0
  181. package/src/telemetry/types.ts +23 -0
  182. package/src/types/config.ts +66 -14
  183. package/src/types/provider.ts +79 -1
  184. package/src/types/request.ts +18 -10
  185. package/src/types/tools.ts +30 -11
  186. package/src/types.ts +1 -0
  187. package/src/usage/command-code-manifest.ts +116 -0
  188. package/src/usage/cost.ts +2 -2
  189. package/src/usage/expected-prices.ts +126 -24
  190. package/src/usage/log.ts +18 -8
  191. package/src/usage/summary.ts +34 -12
  192. package/src/web-search/exa-executor.ts +40 -9
  193. package/src/web-search/index.ts +16 -8
  194. package/src/web-search/loop.ts +5 -4
  195. package/gui/dist/assets/index-DKLr4LTE.js +0 -102
  196. package/gui/dist/assets/index-DrSQdTRd.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,9 @@ 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,
301
305
  // Absent means the historical auto-open, so the GUI can render the toggle
302
306
  // without having to know that `undefined` and `true` mean the same thing.
303
307
  oauthOpenBrowser: config.oauthOpenBrowser !== false,
@@ -386,13 +390,15 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
386
390
  appOwnedMemoryBudgetMb?: unknown;
387
391
  codexAccountPickerEnabled?: unknown;
388
392
  oauthOpenBrowser?: unknown;
393
+ showCodexSparkQuota?: unknown;
389
394
  };
390
395
  if (body.codexAutoStart === undefined
391
396
  && body.streamMode === undefined
392
397
  && body.appOwnedMemoryBudgetMb === undefined
393
398
  && body.codexAccountPickerEnabled === undefined
394
- && body.oauthOpenBrowser === undefined) {
395
- return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, or oauthOpenBrowser" }, 400);
399
+ && body.oauthOpenBrowser === undefined
400
+ && body.showCodexSparkQuota === undefined) {
401
+ return jsonResponse({ error: "provide codexAutoStart, streamMode, appOwnedMemoryBudgetMb, codexAccountPickerEnabled, oauthOpenBrowser, or showCodexSparkQuota" }, 400);
396
402
  }
397
403
  if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
398
404
  return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
@@ -407,6 +413,9 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
407
413
  && typeof body.codexAccountPickerEnabled !== "boolean") {
408
414
  return jsonResponse({ error: "codexAccountPickerEnabled boolean is required" }, 400);
409
415
  }
416
+ if (body.showCodexSparkQuota !== undefined && typeof body.showCodexSparkQuota !== "boolean") {
417
+ return jsonResponse({ error: "showCodexSparkQuota boolean is required" }, 400);
418
+ }
410
419
  if (body.appOwnedMemoryBudgetMb !== undefined && (
411
420
  typeof body.appOwnedMemoryBudgetMb !== "number"
412
421
  || !Number.isInteger(body.appOwnedMemoryBudgetMb)
@@ -428,6 +437,8 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
428
437
  hasCodexAccountPickerEnabled: Object.hasOwn(config, "codexAccountPickerEnabled"),
429
438
  oauthOpenBrowser: config.oauthOpenBrowser,
430
439
  hasOauthOpenBrowser: Object.hasOwn(config, "oauthOpenBrowser"),
440
+ showCodexSparkQuota: config.showCodexSparkQuota,
441
+ hasShowCodexSparkQuota: Object.hasOwn(config, "showCodexSparkQuota"),
431
442
  };
432
443
  const pickerWasEnabled = codexAccountPickerEnabled(config);
433
444
  let pickerIsEnabled = pickerWasEnabled;
@@ -437,7 +448,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
437
448
  }
438
449
  if (body.streamMode !== undefined) {
439
450
  if (body.streamMode === "auto") {
440
- delete config.streamMode;
451
+ deleteConfigTopLevelKey(config, "streamMode");
441
452
  } else {
442
453
  config.streamMode = body.streamMode as "legacy-tee" | "eager-relay";
443
454
  }
@@ -454,25 +465,31 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
454
465
  if (typeof body.oauthOpenBrowser === "boolean") {
455
466
  config.oauthOpenBrowser = body.oauthOpenBrowser;
456
467
  }
468
+ if (typeof body.showCodexSparkQuota === "boolean") {
469
+ config.showCodexSparkQuota = body.showCodexSparkQuota;
470
+ }
457
471
  pickerIsEnabled = codexAccountPickerEnabled(config);
458
472
  (deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode)(config);
459
473
  } catch (error) {
460
474
  if (previousSettings.hasCodexAutoStart) config.codexAutoStart = previousSettings.codexAutoStart;
461
- else delete config.codexAutoStart;
475
+ else deleteConfigTopLevelKey(config, "codexAutoStart");
462
476
  if (previousSettings.hasStreamMode) config.streamMode = previousSettings.streamMode;
463
- else delete config.streamMode;
477
+ else deleteConfigTopLevelKey(config, "streamMode");
464
478
  if (previousSettings.hasAppOwnedMemoryBudgetMb) {
465
479
  config.appOwnedMemoryBudgetMb = previousSettings.appOwnedMemoryBudgetMb;
466
- } else delete config.appOwnedMemoryBudgetMb;
480
+ } else deleteConfigTopLevelKey(config, "appOwnedMemoryBudgetMb");
467
481
  if (previousSettings.hasCodexAccountNamespaces) {
468
482
  config.codexAccountNamespaces = previousSettings.codexAccountNamespaces;
469
- } else delete config.codexAccountNamespaces;
483
+ } else deleteConfigTopLevelKey(config, "codexAccountNamespaces");
470
484
  if (previousSettings.hasCodexAccountPickerEnabled) {
471
485
  config.codexAccountPickerEnabled = previousSettings.codexAccountPickerEnabled;
472
- } else delete config.codexAccountPickerEnabled;
486
+ } else deleteConfigTopLevelKey(config, "codexAccountPickerEnabled");
473
487
  if (previousSettings.hasOauthOpenBrowser) {
474
488
  config.oauthOpenBrowser = previousSettings.oauthOpenBrowser;
475
- } else delete config.oauthOpenBrowser;
489
+ } else deleteConfigTopLevelKey(config, "oauthOpenBrowser");
490
+ if (previousSettings.hasShowCodexSparkQuota) {
491
+ config.showCodexSparkQuota = previousSettings.showCodexSparkQuota;
492
+ } else deleteConfigTopLevelKey(config, "showCodexSparkQuota");
476
493
  throw error;
477
494
  }
478
495
  if (typeof body.appOwnedMemoryBudgetMb === "number") {
@@ -494,6 +511,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
494
511
  codexAccountPickerEnabled: pickerIsEnabled,
495
512
  oauthOpenBrowser: config.oauthOpenBrowser !== false,
496
513
  catalogRefreshPending,
514
+ showCodexSparkQuota: config.showCodexSparkQuota === true,
497
515
  startupHealth: await readStartupHealth(config),
498
516
  });
499
517
  }
@@ -6,6 +6,7 @@ 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
12
  import type { RuntimePortState } from "../../config/process-state";
@@ -91,6 +92,14 @@ export interface ManagementApiDeps {
91
92
  * state inside their temporary Codex home.
92
93
  */
93
94
  codexLogGuardMaintenanceDeps?: CodexLogGuardMaintenanceDeps;
95
+ /**
96
+ * Prompt-layer path seam. Production leaves this unset and
97
+ * `src/codex/prompt-layers.ts` resolves the real CODEX_HOME. A route test
98
+ * that could not inject paths would read AND WRITE the developer's live
99
+ * ~/.codex/config.toml — the same class of incident
100
+ * `saveConfigPreservingClaudeCode` above exists to prevent.
101
+ */
102
+ codexPromptPaths?: CodexPromptPaths;
94
103
  }
95
104
 
96
105
 
@@ -21,7 +21,7 @@ import {
21
21
  submitManualLoginCode,
22
22
  upsertOAuthProvider,
23
23
  } from "../../oauth";
24
- import { removeCredential } from "../../oauth/store";
24
+ import { getAccountSet, removeCredential } from "../../oauth/store";
25
25
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
26
26
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
27
27
  import { deriveProviderPresets } from "../../providers/derive";
@@ -204,10 +204,16 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
204
204
  const filter = {
205
205
  provider: url.searchParams.get("provider"),
206
206
  model: url.searchParams.get("model"),
207
+ account: url.searchParams.get("account"),
207
208
  };
209
+ const fallbackAccounts: Record<string, string> = {};
210
+ for (const p of listOAuthProviders()) {
211
+ const set = getAccountSet(p);
212
+ if (set && set.accounts.length === 1 && set.activeAccountId) fallbackAccounts[p] = set.activeAccountId;
213
+ }
208
214
  const project = <T extends UsageSummary>(summary: T, entries?: PersistedUsageEntry[]) =>
209
- projectUsageSummary(summary, filter, entries);
210
- const filterRequested = Boolean(filter.provider ?? filter.model);
215
+ projectUsageSummary(summary, filter, entries, fallbackAccounts);
216
+ const filterRequested = Boolean(filter.provider ?? filter.model ?? filter.account);
211
217
  const now = Date.now();
212
218
  try {
213
219
  const cacheKey = `${range}:${surface}`;
@@ -242,7 +248,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
242
248
  const revisionReadAt = Date.now();
243
249
  const window = snapshotWindow(snapshot.entries);
244
250
  const summary = {
245
- ...summarizeUsage(snapshot.entries, range, now, surface),
251
+ ...summarizeUsage(snapshot.entries, range, now, surface, fallbackAccounts),
246
252
  historyTruncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated,
247
253
  truncatedPrefixBytes: snapshot.truncatedPrefixBytes,
248
254
  entriesTruncated: snapshot.entriesTruncated,
@@ -270,7 +276,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
270
276
  for (const nextRange of ranges) {
271
277
  for (const nextSurface of surfaces) {
272
278
  const nextSummary = nextRange === range && nextSurface === surface ? summary : {
273
- ...summarizeUsage(snapshot.entries, nextRange, now, nextSurface),
279
+ ...summarizeUsage(snapshot.entries, nextRange, now, nextSurface, fallbackAccounts),
274
280
  historyTruncated: summary.historyTruncated,
275
281
  truncatedPrefixBytes: summary.truncatedPrefixBytes,
276
282
  entriesTruncated: summary.entriesTruncated,
@@ -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) {
@@ -543,6 +707,104 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
543
707
  }
544
708
  return jsonResponse({ selected, available, liveModelCounts });
545
709
  }
710
+ if (url.pathname === "/api/model-presets" && req.method === "GET") {
711
+ // Preview without applying: rules evaluated against the CURRENT catalog, so the count the
712
+ // user sees is the count they would get.
713
+ const models = await fetchAllModels(config);
714
+ const byProvider = new Map<string, string[]>();
715
+ for (const m of models) {
716
+ const ids = byProvider.get(m.provider) ?? [];
717
+ ids.push(m.id);
718
+ byProvider.set(m.provider, ids);
719
+ }
720
+ const providers: Record<string, unknown> = {};
721
+ for (const [name, prov] of Object.entries(config.providers)) {
722
+ const preset = modelPresetFor(name);
723
+ if (!preset) continue;
724
+ const catalogIds = byProvider.get(name) ?? [];
725
+ const presetIds = materializeModelPreset(name, catalogIds);
726
+ providers[name] = {
727
+ mode: prov.modelPreset?.mode ?? "all",
728
+ ...(prov.modelPreset?.appliedVersion !== undefined
729
+ ? { appliedVersion: prov.modelPreset.appliedVersion }
730
+ : {}),
731
+ availableVersion: preset.version,
732
+ presetIds,
733
+ presetCount: presetIds.length,
734
+ totalCount: catalogIds.length,
735
+ ...(prov.modelPreset?.fallback ? { fallback: prov.modelPreset.fallback } : {}),
736
+ };
737
+ }
738
+ return jsonResponse({ providers });
739
+ }
740
+ if (url.pathname === "/api/model-presets" && req.method === "PUT") {
741
+ let body: { provider?: unknown; mode?: unknown };
742
+ try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
743
+ const provider = typeof body.provider === "string" ? body.provider : "";
744
+ if (!provider || !hasOwnProvider(config.providers, provider)) {
745
+ return jsonResponse({ error: "unknown provider" }, provider ? 404 : 400);
746
+ }
747
+ const mode = body.mode;
748
+ if (mode !== "preset" && mode !== "all" && mode !== "custom") {
749
+ return jsonResponse({ error: "mode must be preset, all, or custom" }, 400);
750
+ }
751
+ const target = config.providers[provider];
752
+ if (mode === "all") {
753
+ // Same effect as today's empty-list PUT: no allowlist, no marker to reconcile.
754
+ delete target.selectedModels;
755
+ delete target.modelPreset;
756
+ persistConfig(config);
757
+ return jsonResponse({ ok: true, provider, mode, selected: [], catalogRefresh: await convergeCodexCatalog() });
758
+ }
759
+ if (mode === "custom") {
760
+ // Keep whatever is selected; only the marker changes, so a user can pin their edits
761
+ // without the proxy re-materializing over them.
762
+ target.modelPreset = { ...(target.modelPreset ?? {}), mode: "custom" };
763
+ persistConfig(config);
764
+ return jsonResponse({ ok: true, provider, mode, selected: [...(target.selectedModels ?? [])] });
765
+ }
766
+ if (!hasModelPreset(provider)) {
767
+ return jsonResponse({ error: `no model preset is shipped for provider '${provider}'` }, 400);
768
+ }
769
+ const models = await fetchAllModels(config);
770
+ const catalogIds = models.filter(m => m.provider === provider).map(m => m.id);
771
+ const presetIds = materializeModelPreset(provider, catalogIds);
772
+ const preset = modelPresetFor(provider)!;
773
+ if (presetIds.length === 0) {
774
+ // NEVER write an empty allowlist from a preset: empty means ALL, so it would silently
775
+ // un-curate instead of curating. Keep the previous selection and record the fallback so
776
+ // the next convergence can retry.
777
+ target.modelPreset = {
778
+ mode: "all",
779
+ appliedVersion: preset.version,
780
+ appliedAt: new Date().toISOString(),
781
+ fallback: "preset-empty",
782
+ };
783
+ persistConfig(config);
784
+ return jsonResponse({
785
+ ok: true,
786
+ provider,
787
+ mode: "all",
788
+ fallback: "preset-empty",
789
+ selected: [...(target.selectedModels ?? [])],
790
+ });
791
+ }
792
+ target.selectedModels = presetIds;
793
+ target.modelPreset = {
794
+ mode: "preset",
795
+ appliedVersion: preset.version,
796
+ appliedAt: new Date().toISOString(),
797
+ };
798
+ persistConfig(config);
799
+ return jsonResponse({
800
+ ok: true,
801
+ provider,
802
+ mode: "preset",
803
+ appliedVersion: preset.version,
804
+ selected: presetIds,
805
+ catalogRefresh: await convergeCodexCatalog(),
806
+ });
807
+ }
546
808
  if (url.pathname === "/api/selected-models" && req.method === "PUT") {
547
809
  let body: { provider?: unknown; models?: unknown };
548
810
  try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
@@ -556,6 +818,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
556
818
  // Empty list clears the allowlist (provider reverts to exposing all models).
557
819
  if (models.length > 0) config.providers[provider].selectedModels = models;
558
820
  else delete config.providers[provider].selectedModels;
821
+ // Divergence is detected at the WRITE path, not by diffing (#2465): a user edit while the
822
+ // provider is in preset mode makes the selection theirs, and the proxy must never
823
+ // re-materialize over it afterwards.
824
+ markModelPresetDiverged(config.providers[provider]);
559
825
  persistConfig(config);
560
826
  const catalogRefresh = await convergeCodexCatalog();
561
827
  return jsonResponse({ ok: true, provider, selected: models, catalogRefresh });
@@ -438,10 +438,20 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
438
438
  const body = await readManagementJsonBodyOr(req, {}) as { provider?: unknown; accountId?: unknown };
439
439
  const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
440
440
  const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
441
- if (provider !== "anthropic") return jsonResponse({ error: "clear-cooldown is only supported for anthropic" }, 400);
441
+ const supported = provider === "anthropic" || provider === "google-antigravity" || provider === "cursor" || provider === "command-code";
442
+ if (!supported) return jsonResponse({ error: "clear-cooldown is not supported for this provider" }, 400);
442
443
  if (!accountId) return jsonResponse({ error: "missing accountId" }, 400);
443
- const { clearAnthropicAccountCooldown } = await import("../../oauth/anthropic-routing");
444
- const cleared = clearAnthropicAccountCooldown(accountId);
444
+ let cleared = false;
445
+ if (provider === "anthropic") {
446
+ const { clearAnthropicAccountCooldown } = await import("../../oauth/anthropic-routing");
447
+ cleared = clearAnthropicAccountCooldown(accountId);
448
+ } else if (provider === "google-antigravity") {
449
+ const { clearAntigravityAccountCooldown } = await import("../../oauth/antigravity-routing");
450
+ cleared = clearAntigravityAccountCooldown(accountId);
451
+ } else if (provider === "cursor" || provider === "command-code") {
452
+ const { clearPoolAccountCooldown } = await import("../../routing/account-pool");
453
+ cleared = clearPoolAccountCooldown(provider, accountId);
454
+ }
445
455
  return jsonResponse({ ok: true, cleared });
446
456
  }
447
457