@yansigit/opencodex 2.33.1 → 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 (177) 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 -3
  6. package/src/adapters/anthropic.ts +79 -2
  7. package/src/adapters/command-code.ts +40 -3
  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 +9 -9
  13. package/src/adapters/cursor/live-models.ts +7 -2
  14. package/src/adapters/cursor/live-transport.ts +14 -0
  15. package/src/adapters/cursor/message-mapper.ts +4 -1
  16. package/src/adapters/cursor/native-exec-shell.ts +103 -6
  17. package/src/adapters/cursor/native-exec.ts +20 -0
  18. package/src/adapters/cursor/protobuf-request.ts +74 -39
  19. package/src/adapters/cursor/request-builder.ts +32 -10
  20. package/src/adapters/cursor/tool-definitions.ts +13 -10
  21. package/src/adapters/cursor/tool-result-normalize.ts +35 -3
  22. package/src/adapters/cursor/types.ts +16 -4
  23. package/src/adapters/cursor.ts +97 -70
  24. package/src/adapters/google-antigravity-replay.ts +105 -25
  25. package/src/adapters/google-antigravity-wire.ts +5 -0
  26. package/src/adapters/google-errors.ts +41 -12
  27. package/src/adapters/google-http.ts +12 -11
  28. package/src/adapters/google.ts +156 -65
  29. package/src/adapters/image.ts +1 -1
  30. package/src/adapters/kiro-constants.ts +15 -0
  31. package/src/adapters/kiro-tools.ts +43 -15
  32. package/src/adapters/kiro.ts +54 -9
  33. package/src/adapters/openai-chat.ts +286 -242
  34. package/src/adapters/openai-responses.ts +334 -24
  35. package/src/adapters/run-turn-queue.ts +36 -1
  36. package/src/adapters/tool-catalog-nudge.ts +2 -2
  37. package/src/adapters/xai-tool-schema.ts +436 -0
  38. package/src/bridge.ts +67 -26
  39. package/src/chat/inbound.ts +14 -1
  40. package/src/chat/outbound.ts +15 -7
  41. package/src/claude/agents-inject.ts +8 -1
  42. package/src/claude/outbound.ts +10 -8
  43. package/src/cli/account-api.ts +27 -7
  44. package/src/cli/account-extended.ts +10 -3
  45. package/src/cli/account.ts +29 -5
  46. package/src/cli/alias.ts +66 -0
  47. package/src/cli/claude.ts +26 -1
  48. package/src/cli/dispatch.ts +13 -1
  49. package/src/cli/help.ts +1 -0
  50. package/src/cli/index.ts +5 -0
  51. package/src/cli/init.ts +1 -0
  52. package/src/cli/models-runtime.ts +95 -0
  53. package/src/cli/models.ts +13 -7
  54. package/src/cli/provider-runtime.ts +16 -2
  55. package/src/cli/registry.ts +6 -1
  56. package/src/cli/telemetry-commands.ts +25 -0
  57. package/src/cli/v2.ts +34 -10
  58. package/src/codex/account-pause.ts +2 -1
  59. package/src/codex/account-priority.ts +3 -2
  60. package/src/codex/app-server-processes.ts +80 -6
  61. package/src/codex/auth-api.ts +48 -8
  62. package/src/codex/auth-context.ts +21 -18
  63. package/src/codex/catalog/aggregation.ts +6 -0
  64. package/src/codex/catalog/model-metadata.ts +13 -1
  65. package/src/codex/catalog/native-models.ts +5 -2
  66. package/src/codex/catalog/parsing.ts +16 -0
  67. package/src/codex/catalog/provider-fetch.ts +20 -37
  68. package/src/codex/catalog/sync.ts +127 -2
  69. package/src/codex/catalog.ts +1 -1
  70. package/src/codex/codex-write-lock.ts +3 -1
  71. package/src/codex/convergence-types.ts +1 -1
  72. package/src/codex/convergence.ts +22 -2
  73. package/src/codex/desired-state.ts +2 -2
  74. package/src/codex/desktop-app-restart.ts +18 -5
  75. package/src/codex/inject-coordination.ts +83 -0
  76. package/src/codex/inject.ts +14 -1
  77. package/src/codex/log-guard/inspect.ts +22 -4
  78. package/src/codex/model-entitlements.ts +9 -2
  79. package/src/codex/prompt-layers.ts +371 -25
  80. package/src/codex/prompt-text-probe.ts +238 -0
  81. package/src/codex/quota.ts +123 -18
  82. package/src/codex/routing.ts +9 -0
  83. package/src/codex/subagent-model-fallback.ts +198 -27
  84. package/src/codex/transition-state.ts +107 -8
  85. package/src/combos/types.ts +10 -0
  86. package/src/compatibility/openai-responses.ts +33 -1
  87. package/src/config/autonomous-remediation.ts +21 -0
  88. package/src/config/provider-validation.ts +14 -0
  89. package/src/config/rebase-provenance.ts +68 -0
  90. package/src/config.ts +191 -17
  91. package/src/generated/compatibility-version.json +229 -153
  92. package/src/images/loop.ts +5 -4
  93. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  94. package/src/lab/fabric/producer-child.ts +1 -1
  95. package/src/lib/config-ownership.ts +20 -0
  96. package/src/lib/errors.ts +11 -2
  97. package/src/lib/package-tree-integrity.ts +101 -0
  98. package/src/oauth/aistudio-credentials.ts +65 -0
  99. package/src/oauth/aistudio-native-daemon.ts +54 -0
  100. package/src/oauth/generic-account-failover.ts +231 -0
  101. package/src/oauth/index.ts +57 -5
  102. package/src/oauth/key-providers.ts +10 -1
  103. package/src/oauth/kiro.ts +45 -0
  104. package/src/oauth/login-cli.ts +19 -20
  105. package/src/oauth/types.ts +15 -0
  106. package/src/providers/codex-capacity.ts +5 -2
  107. package/src/providers/command-code-efforts.ts +38 -6
  108. package/src/providers/context-cap.ts +4 -3
  109. package/src/providers/default-aliases.ts +65 -0
  110. package/src/providers/derive.ts +28 -0
  111. package/src/providers/fastwire.ts +7 -1
  112. package/src/providers/model-presets.ts +119 -0
  113. package/src/providers/new-model-policy.ts +146 -0
  114. package/src/providers/provider-id-rewrite.ts +2 -1
  115. package/src/providers/quota.ts +73 -14
  116. package/src/providers/registry.ts +160 -68
  117. package/src/providers/slug-codec.ts +52 -0
  118. package/src/responses/code-mode-helper-compat.ts +50 -0
  119. package/src/responses/custom-tool-compat.ts +34 -10
  120. package/src/responses/parser.ts +4 -0
  121. package/src/responses/schema.ts +5 -1
  122. package/src/responses/thought-signature-replay.ts +17 -0
  123. package/src/router.ts +40 -2
  124. package/src/routing/analytics.ts +1 -0
  125. package/src/routing/quota.ts +10 -0
  126. package/src/server/auth-cors.ts +23 -0
  127. package/src/server/chat-completions.ts +24 -16
  128. package/src/server/chat-native-sse.ts +3 -3
  129. package/src/server/chat-native.ts +30 -11
  130. package/src/server/claude-messages.ts +1 -1
  131. package/src/server/effort-policy.ts +16 -0
  132. package/src/server/index.ts +152 -80
  133. package/src/server/lifecycle.ts +52 -1
  134. package/src/server/management/agent-settings-routes.ts +31 -15
  135. package/src/server/management/codex-prompt-routes.ts +570 -0
  136. package/src/server/management/combo-routes.ts +2 -1
  137. package/src/server/management/config-routes.ts +27 -9
  138. package/src/server/management/context.ts +9 -0
  139. package/src/server/management/logs-usage-routes.ts +1 -1
  140. package/src/server/management/model-routes.ts +266 -0
  141. package/src/server/management/provider-routes.ts +137 -3
  142. package/src/server/management/routing-profile-routes.ts +2 -2
  143. package/src/server/management-api.ts +2 -0
  144. package/src/server/relay-eager.ts +147 -20
  145. package/src/server/relay.ts +251 -19
  146. package/src/server/request-log-conversation.ts +21 -0
  147. package/src/server/request-log.ts +46 -20
  148. package/src/server/responses/collaboration.ts +42 -5
  149. package/src/server/responses/combo-stream-preflight.ts +10 -3
  150. package/src/server/responses/core.ts +571 -137
  151. package/src/server/responses/empty-completion-guard.ts +35 -0
  152. package/src/server/responses/fetch-helpers.ts +14 -6
  153. package/src/server/responses/input-admission.ts +3 -1
  154. package/src/server/responses/passthrough-error.ts +33 -9
  155. package/src/server/responses/responses-field-backfill.ts +105 -13
  156. package/src/server/responses/ws-upstream.ts +35 -5
  157. package/src/server/responses-custom-tool-repair.ts +52 -7
  158. package/src/server/responses-terminal-repair.ts +25 -4
  159. package/src/server/sse-frame-buffer.ts +31 -4
  160. package/src/server/ws-bridge.ts +15 -4
  161. package/src/telemetry/dispatcher.ts +44 -0
  162. package/src/telemetry/fingerprint.ts +24 -0
  163. package/src/telemetry/hook.ts +43 -0
  164. package/src/telemetry/ledger.ts +54 -0
  165. package/src/telemetry/types.ts +23 -0
  166. package/src/types/config.ts +66 -14
  167. package/src/types/provider.ts +77 -0
  168. package/src/types/request.ts +16 -10
  169. package/src/types/tools.ts +20 -25
  170. package/src/types.ts +1 -0
  171. package/src/usage/expected-prices.ts +43 -24
  172. package/src/usage/log.ts +17 -7
  173. package/src/web-search/exa-executor.ts +40 -9
  174. package/src/web-search/loop.ts +5 -4
  175. package/gui/dist/assets/index-CIDo4y4k.js +0 -102
  176. package/gui/dist/assets/index-DrSQdTRd.css +0 -1
  177. package/src/server/aistudio-ws-hub.ts +0 -295
@@ -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
 
@@ -209,7 +209,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
209
209
  const fallbackAccounts: Record<string, string> = {};
210
210
  for (const p of listOAuthProviders()) {
211
211
  const set = getAccountSet(p);
212
- if (set?.activeAccountId) fallbackAccounts[p] = set.activeAccountId;
212
+ if (set && set.accounts.length === 1 && set.activeAccountId) fallbackAccounts[p] = set.activeAccountId;
213
213
  }
214
214
  const project = <T extends UsageSummary>(summary: T, entries?: PersistedUsageEntry[]) =>
215
215
  projectUsageSummary(summary, filter, entries, fallbackAccounts);
@@ -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 });
@@ -97,6 +97,9 @@ import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostR
97
97
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
98
98
  import type { ManagementContext } from "./context";
99
99
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
100
+ import { resolveAiStudioCredentials } from "../../oauth/aistudio-credentials";
101
+ import { buildAiStudioHeaders, parseGoogleCookieJar } from "../../oauth/google-aistudio-auth";
102
+ import { maxWsFrameBytesConfigError, wsUpstreamConfigError } from "../../config/provider-validation";
100
103
 
101
104
  type ProviderPatchApplication =
102
105
  | { error: string }
@@ -108,6 +111,93 @@ type ProviderPatchApplication =
108
111
  headersTouched: boolean;
109
112
  };
110
113
 
114
+ const AI_STUDIO_REAUTH_ERROR = "Session expired or missing — re-authentication required";
115
+ const AI_STUDIO_PROBE_TIMEOUT_MS = 8_000;
116
+ const AI_STUDIO_PROBE_MODEL = "gemini-2.5-flash";
117
+ const AI_STUDIO_ORIGIN = "https://aistudio.google.com";
118
+
119
+ let aiStudioProbeFetchForTests: typeof fetch | undefined;
120
+
121
+ export function setAiStudioProbeFetchForTests(fetchImpl?: typeof fetch): void {
122
+ aiStudioProbeFetchForTests = fetchImpl;
123
+ }
124
+
125
+ function isAiStudioHtmlSignIn(text: string): boolean {
126
+ const lower = text.trim().toLowerCase();
127
+ return lower.startsWith("<!doctype") || lower.startsWith("<html") || lower.includes("accounts.google.com/v3/signin");
128
+ }
129
+
130
+ async function probeAiStudioLiveSession(
131
+ name: string,
132
+ prov: OcxProviderConfig,
133
+ ): Promise<{ ok: boolean; latencyMs: number; authState?: "connected" | "checking" | "needs_reauth" | "unsupported"; message?: string; error?: string }> {
134
+ const credentials = resolveAiStudioCredentials(prov);
135
+ if (credentials.kind !== "ready") {
136
+ return { ok: false, latencyMs: 0, error: AI_STUDIO_REAUTH_ERROR };
137
+ }
138
+ if (process.platform !== "darwin") {
139
+ return {
140
+ ok: true,
141
+ latencyMs: 0,
142
+ authState: "unsupported",
143
+ message: "AI Studio credentials configured; native login is unsupported on this platform",
144
+ };
145
+ }
146
+
147
+ const base = (prov.baseUrl || "https://alkalimakersuite-pa.clients6.google.com").replace(/\/+$/, "");
148
+ const url = base + "/v1internal:generateContent";
149
+ const jar = parseGoogleCookieJar(credentials.cookieHeader);
150
+ const headers = await buildAiStudioHeaders(jar, AI_STUDIO_ORIGIN);
151
+ const body = JSON.stringify({
152
+ model: AI_STUDIO_PROBE_MODEL,
153
+ contents: [{ role: "user", parts: [{ text: "ping" }] }],
154
+ generationConfig: { maxOutputTokens: 1 },
155
+ });
156
+
157
+ const controller = new AbortController();
158
+ const timer = setTimeout(() => controller.abort(), AI_STUDIO_PROBE_TIMEOUT_MS);
159
+ const started = Date.now();
160
+ try {
161
+ const outboundProvider = aiStudioProbeFetchForTests
162
+ ? { ...prov, fetch: aiStudioProbeFetchForTests }
163
+ : prov;
164
+ const response = await providerOutboundPost(name, outboundProvider, url, {
165
+ headers,
166
+ body,
167
+ signal: controller.signal,
168
+ });
169
+ const latencyMs = Date.now() - started;
170
+ const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
171
+ const text = await response.text().catch(() => "");
172
+
173
+ if ((response.status >= 300 && response.status < 400) || response.status === 401 || response.status === 403) {
174
+ return { ok: false, latencyMs, error: AI_STUDIO_REAUTH_ERROR };
175
+ }
176
+ if (contentType.includes("text/html") || isAiStudioHtmlSignIn(text)) {
177
+ return { ok: false, latencyMs, error: AI_STUDIO_REAUTH_ERROR };
178
+ }
179
+ if (response.status !== 200) {
180
+ return { ok: false, latencyMs, error: "AI Studio connection probe failed" };
181
+ }
182
+ try {
183
+ JSON.parse(text);
184
+ } catch {
185
+ return { ok: false, latencyMs, error: "AI Studio connection probe failed" };
186
+ }
187
+ return {
188
+ ok: true,
189
+ latencyMs,
190
+ authState: "connected",
191
+ message: "AI Studio session verified",
192
+ };
193
+ } catch {
194
+ return { ok: false, latencyMs: Date.now() - started, error: "AI Studio connection probe failed" };
195
+ } finally {
196
+ clearTimeout(timer);
197
+ }
198
+ }
199
+
200
+
111
201
  /**
112
202
  * Apply the recognized PATCH field mask onto a provider copy. The caller runs this once
113
203
  * for validation and again inside the config mutation lock against the newest provider,
@@ -206,6 +296,28 @@ function applyProviderPatchFields(
206
296
  next.liveModels = rawBody.liveModels;
207
297
  touched = true;
208
298
  }
299
+ if (Object.hasOwn(rawBody, "wsUpstream")) {
300
+ const value = rawBody.wsUpstream;
301
+ if (value === null) {
302
+ delete next.wsUpstream;
303
+ } else {
304
+ const error = wsUpstreamConfigError(value);
305
+ if (error) return { error };
306
+ next.wsUpstream = value as boolean;
307
+ }
308
+ touched = true;
309
+ }
310
+ if (Object.hasOwn(rawBody, "maxWsFrameBytes")) {
311
+ const value = rawBody.maxWsFrameBytes;
312
+ if (value === null) {
313
+ delete next.maxWsFrameBytes;
314
+ } else {
315
+ const error = maxWsFrameBytesConfigError(value);
316
+ if (error) return { error };
317
+ next.maxWsFrameBytes = value as number;
318
+ }
319
+ touched = true;
320
+ }
209
321
  if (Object.hasOwn(rawBody, "xaiResponsesOptIn")) {
210
322
  if (name !== "xai") return { error: "xaiResponsesOptIn is valid only for provider xai" };
211
323
  if (typeof rawBody.xaiResponsesOptIn !== "boolean") {
@@ -482,6 +594,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
482
594
  contextWindow: p.contextWindow,
483
595
  modelContextWindows: p.modelContextWindows,
484
596
  modelAutoCompactTokenLimits: p.modelAutoCompactTokenLimits,
597
+ wsUpstream: p.wsUpstream,
598
+ maxWsFrameBytes: p.maxWsFrameBytes,
485
599
  modelSupportsServiceTier: p.modelSupportsServiceTier,
486
600
  noStructuredOutputModels: p.noStructuredOutputModels,
487
601
  upstreamHttpVersion: p.upstreamHttpVersion,
@@ -575,15 +689,26 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
575
689
  let body: { name?: unknown; provider?: unknown; setDefault?: boolean };
576
690
  try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid JSON body" }, 400); }
577
691
  const name = typeof body.name === "string" ? body.name.trim() : "";
578
- const providerError = providerManagementConfigError(name, body.provider);
692
+ const submittedProvider = isPlainRecord(body.provider)
693
+ ? structuredClone(body.provider) as Record<string, unknown>
694
+ : body.provider;
695
+ const submittedCredential = isPlainRecord(submittedProvider)
696
+ && isPlainRecord(submittedProvider.azureCredential)
697
+ ? submittedProvider.azureCredential as Record<string, unknown>
698
+ : undefined;
699
+ if (typeof submittedCredential?.managedIdentityClientId === "string") {
700
+ submittedCredential.managedIdentityClientId = submittedCredential.managedIdentityClientId.trim();
701
+ }
702
+ const providerError = providerManagementConfigError(name, submittedProvider);
579
703
  if (providerError) return jsonResponse({ error: providerError }, 400);
580
- const serviceTierError = providerServiceTierConfigError(name, body.provider);
704
+ const serviceTierError = providerServiceTierConfigError(name, submittedProvider);
581
705
  if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400);
582
- const prov = body.provider ? stripCodexRuntimeProviderFields(body.provider as OcxProviderConfig) : undefined;
706
+ const prov = submittedProvider ? stripCodexRuntimeProviderFields(submittedProvider as OcxProviderConfig) : undefined;
583
707
  // PATCH already clears on null; POST persisted the body as submitted, so a `null` here
584
708
  // reached disk and the next loadConfig() refused it. Canonicalize to absent, which is what
585
709
  // "clear" means everywhere else.
586
710
  if (prov && prov.upstreamHttpVersion === null) delete prov.upstreamHttpVersion;
711
+ if (prov && prov.wsUpstream === null) delete prov.wsUpstream;
587
712
  if (!name || !prov?.adapter || !prov?.baseUrl) {
588
713
  return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400);
589
714
  }
@@ -628,6 +753,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
628
753
  // erase hand-edited per-model prices from Logs/Usage estimates.
629
754
  const existingCosts = config.providers[name]?.modelCosts;
630
755
  if (existingCosts && !prov.modelCosts) prov.modelCosts = existingCosts;
756
+ // And to the per-provider account-failover opt-out (#2568d). `ProviderPayload` has no
757
+ // member for it either, so an add/edit save structurally cannot carry it — and dropping it
758
+ // silently ENABLES rotation, because activation is presence-driven once the knob is gone.
759
+ // An overwrite must not spend a second subscription account's quota as a side effect.
760
+ const existingFailover = config.providers[name]?.oauthAccountFailover;
761
+ if (existingFailover && !prov.oauthAccountFailover) prov.oauthAccountFailover = existingFailover;
631
762
  // ...and to hand-edited context windows. `ProviderPayload` (gui/src/provider-payload.ts)
632
763
  // has no member for either field, so the add/edit form structurally cannot send them:
633
764
  // absence in the request means "not carried", never "the user deleted it". Deletion goes
@@ -838,6 +969,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
838
969
  message: "Passthrough provider is configured (forwards your Codex login; no upstream /models).",
839
970
  });
840
971
  }
972
+ if (prov.googleMode === "ai-studio-web" || name === "google-aistudio") {
973
+ return jsonResponse(await probeAiStudioLiveSession(name, prov));
974
+ }
841
975
  if (prov.liveModels === false) {
842
976
  // A static catalog has no live discovery endpoint to test. This is neither
843
977
  // positive connectivity evidence nor an outage, and it must stay before
@@ -20,7 +20,7 @@ import { assemblePolicyCandidateEvidence } from "../../routing/compatibility/ass
20
20
  import { activateLab, labActivationRequired } from "../../lib/lab-activation";
21
21
  import { quotaEvidenceForCandidate } from "../../routing/quota";
22
22
  import { routedProviderConfig } from "../../router";
23
- import { saveConfigPreservingClaudeCode, getConfigDir } from "../../config";
23
+ import { deleteConfigTopLevelKey, saveConfigPreservingClaudeCode, getConfigDir } from "../../config";
24
24
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
25
25
  import { isPlainRecord } from "./shared";
26
26
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
@@ -337,7 +337,7 @@ export async function handleRoutingProfileRoutes(ctx: ManagementContext): Promis
337
337
  const nextProfiles = { ...(config.routingProfiles ?? {}) };
338
338
  delete nextProfiles[id];
339
339
  if (Object.keys(nextProfiles).length > 0) config.routingProfiles = nextProfiles;
340
- else delete config.routingProfiles;
340
+ else deleteConfigTopLevelKey(config, "routingProfiles");
341
341
  const saveConfigPreservingClaudeCodeSafe = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
342
342
  saveConfigPreservingClaudeCodeSafe(config);
343
343
  reconcileLiveStateStores();