@yansigit/opencodex 2.36.1-dev.20260829.47 → 2.36.1-dev.20260829.48

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 (43) hide show
  1. package/gui/dist/assets/{ApiKeys-DAuYQsGJ.js → ApiKeys-LOOFiZfv.js} +1 -1
  2. package/gui/dist/assets/{Claude-bN_80RT8.js → Claude-BwKzpXe3.js} +1 -1
  3. package/gui/dist/assets/{CodexSet-D3dWX_ay.js → CodexSet-B5qi9KeE.js} +1 -1
  4. package/gui/dist/assets/{FileIntegrationPage-BGyzkPs2.js → FileIntegrationPage-B0sUef6W.js} +1 -1
  5. package/gui/dist/assets/{Grok-CuaKd1TT.js → Grok-bsb4n-f4.js} +1 -1
  6. package/gui/dist/assets/{Integrations-C7NGQoxR.js → Integrations-CSMwFDvM.js} +2 -2
  7. package/gui/dist/assets/{IntegrationsOverview-DjVorYO5.js → IntegrationsOverview-CTORdKJA.js} +1 -1
  8. package/gui/dist/assets/{Logs-B21a-9xe.js → Logs-BWYbfELf.js} +1 -1
  9. package/gui/dist/assets/{Models-CN7Q3kuK.js → Models-CexEtdT1.js} +1 -1
  10. package/gui/dist/assets/{NumberStepper-BWg90tEs.js → NumberStepper-BapSFQnW.js} +1 -1
  11. package/gui/dist/assets/{Providers-CP_i-7LS.js → Providers-BwUFeAgA.js} +1 -1
  12. package/gui/dist/assets/{RestoreDialog-3M0NqCiD.js → RestoreDialog-CD8piq90.js} +1 -1
  13. package/gui/dist/assets/{Startup-BVPFoP2s.js → Startup-BgX731C2.js} +1 -1
  14. package/gui/dist/assets/{Storage-BdsG9ypz.js → Storage-Bgq7HphP.js} +1 -1
  15. package/gui/dist/assets/{Subagents-CguR_bGQ.js → Subagents-ChxItbeF.js} +1 -1
  16. package/gui/dist/assets/{Usage-C0kKi9_H.js → Usage-BkylzP50.js} +1 -1
  17. package/gui/dist/assets/{data-surface-BCycbObp.js → data-surface-pCXChiBf.js} +1 -1
  18. package/gui/dist/assets/{index-CU1jE0st.js → index-CNopOid3.js} +2 -2
  19. package/gui/dist/assets/{model-display-D2elXkuE.js → model-display-CiUpg8T9.js} +1 -1
  20. package/gui/dist/assets/{provider-payload-DfkXWE7v.js → provider-payload-b2jlS-On.js} +1 -1
  21. package/gui/dist/index.html +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/config-command.ts +7 -3
  24. package/src/cli/init.ts +2 -2
  25. package/src/cli/provider.ts +58 -74
  26. package/src/codex/convergence.ts +110 -15
  27. package/src/config.ts +209 -11
  28. package/src/generated/compatibility-version.json +24 -20
  29. package/src/oauth/index.ts +143 -82
  30. package/src/oauth/login-cli.ts +53 -11
  31. package/src/providers/alibaba-region-startup.ts +31 -11
  32. package/src/providers/api-keys.ts +58 -34
  33. package/src/providers/key-failover.ts +59 -46
  34. package/src/providers/model-rename-startup.ts +25 -7
  35. package/src/providers/openai-tier-startup.ts +38 -14
  36. package/src/server/index.ts +10 -0
  37. package/src/server/management/context.ts +10 -3
  38. package/src/server/management/logs-usage-routes.ts +19 -16
  39. package/src/server/management/model-routes.ts +284 -165
  40. package/src/server/management/oauth-account-routes.ts +93 -15
  41. package/src/server/management/provider-routes.ts +200 -87
  42. package/src/storage/policy-input.ts +166 -0
  43. package/src/storage/policy.ts +59 -211
@@ -64,10 +64,11 @@ import { AUTH_MATRIX, isAllowedRequestOrigin, jsonResponse, providerManagementCo
64
64
  import { applySystemEnvToggle } from "../system-env";
65
65
  import { buildApiAccessEndpoints } from "./api-access";
66
66
  import { isAzureIdentityProvider } from "../../config/provider-validation";
67
+ import { apiKeyPoolEntryId } from "../../providers/api-keys";
67
68
 
68
69
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
69
70
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
70
- import { saveManagementConfig, type ManagementContext } from "./context";
71
+ import { mutateManagementConfig, saveManagementConfig, type ManagementContext } from "./context";
71
72
  import { readManagementJsonBody, readManagementJsonBodyOr, rethrowManagementBodyTooLarge } from "./body";
72
73
  import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
73
74
  import { ACCOUNT_IMPORT_DEADLINE_MS, ACCOUNT_IMPORT_MAX_REQUEST_BYTES } from "../../oauth/account-import";
@@ -95,6 +96,44 @@ async function readJsonBody(req: Request): Promise<Record<string, unknown> | nul
95
96
  }
96
97
  }
97
98
 
99
+ function providerKeyPool(provider: OcxProviderConfig): NonNullable<OcxProviderConfig["apiKeyPool"]> {
100
+ const pool = provider.apiKeyPool ??= [];
101
+ if (pool.length === 0 && provider.apiKey) {
102
+ pool.push({ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey });
103
+ }
104
+ return pool;
105
+ }
106
+
107
+ function providerUsesKeyAuth(provider: OcxProviderConfig): boolean {
108
+ return !isAzureIdentityProvider(provider) && provider.authMode !== "oauth" && provider.authMode !== "forward";
109
+ }
110
+
111
+ function mutateProviderKey<T>(
112
+ deps: ManagementContext["deps"],
113
+ config: OcxConfig,
114
+ name: string,
115
+ mutate: (provider: OcxProviderConfig) => T | null,
116
+ ): { ok: true; value: T } | { ok: false; error: string; status: number } {
117
+ const outcome = mutateManagementConfig(deps, fresh => {
118
+ const provider = fresh.providers[name];
119
+ if (!provider || !providerUsesKeyAuth(provider)) {
120
+ return { changed: false, value: null };
121
+ }
122
+ const before = JSON.stringify(provider);
123
+ const value = mutate(provider);
124
+ return {
125
+ changed: value !== null && JSON.stringify(provider) !== before,
126
+ value: value === null ? null : { provider: structuredClone(provider), value },
127
+ };
128
+ });
129
+ if (outcome.status === "unavailable") {
130
+ return { ok: false, error: outcome.reason === "conflict" ? "config changed; retry" : `config is ${outcome.reason}`, status: outcome.reason === "conflict" ? 409 : 500 };
131
+ }
132
+ if (outcome.value === null) return { ok: false, error: "key not found", status: 404 };
133
+ config.providers[name] = outcome.value.provider;
134
+ return { ok: true, value: outcome.value.value };
135
+ }
136
+
98
137
  /**
99
138
  * The single place key-name rules live. The config read schema is deliberately
100
139
  * permissive so an existing config can never become unloadable; this is the write
@@ -560,27 +599,48 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
560
599
  const body = await readManagementJsonBodyOr(req, {}) as { name?: string; key?: string; label?: string };
561
600
  const name = (body.name ?? "").trim();
562
601
  if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
563
- if (isAzureIdentityProvider(config.providers[name]!)) return jsonResponse({ error: "provider does not use API-key auth" }, 400);
602
+ if (!providerUsesKeyAuth(config.providers[name]!)) return jsonResponse({ error: "provider does not use API-key auth" }, 400);
564
603
  if (typeof body.key !== "string" || !body.key.trim()) return jsonResponse({ error: "key is required" }, 400);
565
- const { addProviderApiKey } = await import("../../providers/api-keys");
566
- const result = addProviderApiKey(config, name, body.key, body.label);
567
- if ("error" in result) return jsonResponse({ error: result.error }, 400);
604
+ const key = body.key.trim();
605
+ if (/[\r\n]/.test(key)) return jsonResponse({ error: "key must not include line breaks" }, 400);
606
+ const result = mutateProviderKey(deps, config, name, provider => {
607
+ const pool = providerKeyPool(provider);
608
+ const label = body.label?.trim();
609
+ const existing = pool.find(entry => entry.key === key);
610
+ if (existing) {
611
+ if (label) existing.label = label;
612
+ provider.apiKey = existing.key;
613
+ return { id: existing.id };
614
+ }
615
+ const id = apiKeyPoolEntryId(key);
616
+ if (pool.some(entry => entry.id === id)) return { error: "API-key pool ID collision" };
617
+ pool.push({ id, key, ...(label ? { label } : {}), addedAt: Date.now() });
618
+ provider.apiKey = key;
619
+ return { id };
620
+ });
621
+ if (!result.ok) return jsonResponse({ error: result.error }, result.status);
622
+ if ("error" in result.value) return jsonResponse({ error: result.value.error }, 409);
568
623
  const { clearModelCache } = await import("../../codex/model-cache");
569
624
  clearModelCache(name);
570
625
  const { clearProviderQuotaCache } = await import("../../providers/quota");
571
626
  clearProviderQuotaCache();
572
627
  const { clearKeyCooldowns } = await import("../../providers/key-failover");
573
628
  clearKeyCooldowns(name); // manual key management resets 429 cooldown state
574
- return jsonResponse({ ok: true, id: result.id }, 201);
629
+ return jsonResponse({ ok: true, id: result.value.id }, 201);
575
630
  }
576
631
  if (url.pathname === "/api/providers/keys/active" && req.method === "PUT") {
577
632
  const body = await readManagementJsonBodyOr(req, {}) as { name?: string; id?: string };
578
633
  const name = (body.name ?? "").trim();
579
634
  if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
580
- if (isAzureIdentityProvider(config.providers[name]!)) return jsonResponse({ error: "provider does not use API-key auth" }, 400);
635
+ if (!providerUsesKeyAuth(config.providers[name]!)) return jsonResponse({ error: "provider does not use API-key auth" }, 400);
581
636
  if (!body.id) return jsonResponse({ error: "missing id" }, 400);
582
- const { setActiveProviderApiKey } = await import("../../providers/api-keys");
583
- if (!setActiveProviderApiKey(config, name, body.id)) return jsonResponse({ error: "key not found" }, 404);
637
+ const result = mutateProviderKey(deps, config, name, provider => {
638
+ const entry = providerKeyPool(provider).find(candidate => candidate.id === body.id);
639
+ if (!entry) return null;
640
+ provider.apiKey = entry.key;
641
+ return true;
642
+ });
643
+ if (!result.ok) return jsonResponse({ error: result.error }, result.status);
584
644
  const { clearModelCache } = await import("../../codex/model-cache");
585
645
  clearModelCache(name);
586
646
  const { clearProviderQuotaCache } = await import("../../providers/quota");
@@ -595,23 +655,41 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
595
655
  const id = typeof body.id === "string" ? body.id.trim() : "";
596
656
  const alias = typeof body.alias === "string" ? body.alias.trim() : "";
597
657
  if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
598
- if (isAzureIdentityProvider(config.providers[name]!)) return jsonResponse({ error: "provider does not use API-key auth" }, 400);
658
+ if (!providerUsesKeyAuth(config.providers[name]!)) return jsonResponse({ error: "provider does not use API-key auth" }, 400);
599
659
  if (!id) return jsonResponse({ error: "missing id" }, 400);
600
660
  if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
601
661
  return jsonResponse({ error: "alias must be at most 80 printable characters" }, 400);
602
662
  }
603
- const { setProviderApiKeyLabel } = await import("../../providers/api-keys");
604
- if (!setProviderApiKeyLabel(config, name, id, alias || undefined)) return jsonResponse({ error: "key not found" }, 404);
663
+ const result = mutateProviderKey(deps, config, name, provider => {
664
+ const entry = providerKeyPool(provider).find(candidate => candidate.id === id);
665
+ if (!entry) return null;
666
+ if (alias) entry.label = alias;
667
+ else delete entry.label;
668
+ return true;
669
+ });
670
+ if (!result.ok) return jsonResponse({ error: result.error }, result.status);
605
671
  return jsonResponse({ ok: true, name, id, alias: alias || null });
606
672
  }
607
673
  if (url.pathname === "/api/providers/keys" && req.method === "DELETE") {
608
674
  const name = (url.searchParams.get("name") ?? "").trim();
609
675
  const id = url.searchParams.get("id") ?? "";
610
676
  if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
611
- if (isAzureIdentityProvider(config.providers[name]!)) return jsonResponse({ error: "provider does not use API-key auth" }, 400);
677
+ if (!providerUsesKeyAuth(config.providers[name]!)) return jsonResponse({ error: "provider does not use API-key auth" }, 400);
612
678
  if (!id) return jsonResponse({ error: "missing id" }, 400);
613
- const { removeProviderApiKey } = await import("../../providers/api-keys");
614
- if (!removeProviderApiKey(config, name, id)) return jsonResponse({ error: "key not found" }, 404);
679
+ const result = mutateProviderKey(deps, config, name, provider => {
680
+ const pool = providerKeyPool(provider);
681
+ const entry = pool.find(candidate => candidate.id === id);
682
+ if (!entry) return null;
683
+ provider.apiKeyPool = pool.filter(candidate => candidate.id !== id);
684
+ if (provider.apiKey === entry.key) {
685
+ const next = provider.apiKeyPool[0];
686
+ if (next) provider.apiKey = next.key;
687
+ else delete provider.apiKey;
688
+ }
689
+ if (provider.apiKeyPool.length === 0) delete provider.apiKeyPool;
690
+ return true;
691
+ });
692
+ if (!result.ok) return jsonResponse({ error: result.error }, result.status);
615
693
  const { clearModelCache } = await import("../../codex/model-cache");
616
694
  clearModelCache(name);
617
695
  const { clearProviderQuotaCache } = await import("../../providers/quota");
@@ -29,6 +29,7 @@ import {
29
29
  upsertOAuthProvider,
30
30
  } from "../../oauth";
31
31
  import { replaceProviderAccountSet } from "../../oauth/store";
32
+ import { comboPublicModelId } from "../../combos/types";
32
33
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
33
34
  import {
34
35
  antigravityOAuthDestinationConfigError,
@@ -51,6 +52,7 @@ import {
51
52
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
52
53
  import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
53
54
  import { clearKeyCooldowns } from "../../providers/key-failover";
55
+ import { apiKeyPoolEntryId } from "../../providers/api-keys";
54
56
  import { providerRequestPacingStatus } from "../../providers/request-pacing";
55
57
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
56
58
  import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
@@ -65,6 +67,7 @@ import { getUsageDebugLogEntries } from "../../usage/debug";
65
67
  import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
66
68
  import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
67
69
  import { getProviderRegistryEntry } from "../../providers/registry";
70
+ import { dropProviderCustomModels } from "../../providers/provider-id-rewrite";
68
71
  import { getDebugLogEntries } from "../../lib/debug-log-buffer";
69
72
  import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log";
70
73
  import {
@@ -94,7 +97,7 @@ import {
94
97
 
95
98
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
96
99
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
97
- import { saveManagementConfig, type ManagementContext } from "./context";
100
+ import { mutateManagementConfig, saveManagementConfig, type ManagementContext } from "./context";
98
101
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
99
102
  import { resolveAiStudioCredentials } from "../../oauth/aistudio-credentials";
100
103
  import { buildAiStudioHeaders, parseGoogleCookieJar } from "../../oauth/google-aistudio-auth";
@@ -110,6 +113,93 @@ type ProviderPatchApplication =
110
113
  headersTouched: boolean;
111
114
  };
112
115
 
116
+ type ProviderMutationValue =
117
+ | { config: OcxConfig; fallbackDefault?: string; droppedCustomModels?: number }
118
+ | {
119
+ error: string;
120
+ code?: string;
121
+ status?: number;
122
+ combos?: string[];
123
+ routingProfiles?: string[];
124
+ };
125
+
126
+ function reconcileSubmittedApiKey(provider: OcxProviderConfig): string | undefined {
127
+ if (!provider.apiKey || !provider.apiKeyPool) return;
128
+ const key = provider.apiKey.trim();
129
+ if (!key || /[\r\n]/.test(key)) return;
130
+ if (provider.apiKeyPool.some(entry => entry.key === key)) {
131
+ provider.apiKey = key;
132
+ return;
133
+ }
134
+ const id = apiKeyPoolEntryId(key);
135
+ if (provider.apiKeyPool.some(entry => entry.id === id)) return "API-key pool ID collision";
136
+ provider.apiKeyPool.push({ id, key, addedAt: Date.now() });
137
+ provider.apiKey = key;
138
+ }
139
+
140
+ function providerDependencies(config: OcxConfig, name: string): {
141
+ combos: string[];
142
+ routingProfiles: string[];
143
+ } {
144
+ return {
145
+ combos: Object.entries(config.combos ?? {})
146
+ .filter(([, combo]) => combo.targets.some(target => target.provider === name))
147
+ .map(([id]) => id)
148
+ .sort((a, b) => a.localeCompare(b)),
149
+ routingProfiles: Object.entries(config.routingProfiles ?? {})
150
+ .filter(([, profile]) => profile.candidates.some(candidate => candidate.provider === name))
151
+ .map(([id]) => id)
152
+ .sort((a, b) => a.localeCompare(b)),
153
+ };
154
+ }
155
+
156
+ function providerDependencyError(config: OcxConfig, name: string): Extract<ProviderMutationValue, { error: string }> | null {
157
+ const dependencies = providerDependencies(config, name);
158
+ if (dependencies.routingProfiles.length > 0) return {
159
+ error: `cannot delete provider "${name}" while routing profiles depend on it`,
160
+ code: "provider_has_dependent_routing_profiles",
161
+ routingProfiles: dependencies.routingProfiles,
162
+ };
163
+ if (dependencies.combos.length > 0) return {
164
+ error: `cannot delete provider "${name}" while combos depend on it`,
165
+ code: "provider_has_dependent_combos",
166
+ combos: dependencies.combos,
167
+ };
168
+ return null;
169
+ }
170
+
171
+ function providerNamespaceCollisionError(config: OcxConfig, name: string): string | undefined {
172
+ const accountCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
173
+ if (accountCollision) return accountCollision;
174
+ const comboCollision = (name === "combo" && Object.keys(config.combos ?? {}).length > 0)
175
+ || Object.entries(config.combos ?? {}).some(([id, combo]) => {
176
+ const publicId = comboPublicModelId(id, combo);
177
+ return id === name || publicId === name || publicId.startsWith(`${name}/`);
178
+ });
179
+ if (comboCollision) {
180
+ return "provider name must not collide with a configured combo namespace";
181
+ }
182
+ const profileCollision = Object.values(config.routingProfiles ?? {}).some(profile => {
183
+ const alias = profile.alias?.trim();
184
+ return alias === name || alias?.startsWith(`${name}/`);
185
+ });
186
+ return profileCollision
187
+ ? "provider name must not collide with a configured routing profile namespace"
188
+ : undefined;
189
+ }
190
+
191
+ function adoptCommittedConfig(target: OcxConfig, source: OcxConfig): void {
192
+ for (const key of Object.keys(target)) delete (target as unknown as Record<string, unknown>)[key];
193
+ Object.assign(target, structuredClone(source));
194
+ }
195
+
196
+ function unavailableMutationResponse(reason: "missing" | "invalid" | "conflict", req: Request, config: OcxConfig): Response {
197
+ const message = reason === "conflict"
198
+ ? "config changed while applying this update; retry"
199
+ : `config is ${reason}`;
200
+ return jsonResponse({ error: message }, reason === "conflict" ? 409 : 500, req, config);
201
+ }
202
+
113
203
  const AI_STUDIO_REAUTH_ERROR = "Session expired or missing — re-authentication required";
114
204
  const AI_STUDIO_PROBE_TIMEOUT_MS = 8_000;
115
205
  const AI_STUDIO_PROBE_MODEL = "gemini-2.5-flash";
@@ -714,7 +804,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
714
804
  if (!isValidProviderName(name)) {
715
805
  return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
716
806
  }
717
- const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
807
+ const namespaceCollision = providerNamespaceCollisionError(config, name);
718
808
  if (namespaceCollision) {
719
809
  return jsonResponse({ error: namespaceCollision }, 409);
720
810
  }
@@ -731,65 +821,56 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
731
821
  if (body.setDefault === true && prov.disabled) {
732
822
  return jsonResponse({ error: "cannot set a disabled provider as default", code: "default_provider_disabled" }, 400);
733
823
  }
734
- // Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
735
- // doesn't send — merge it in so the sidecars are gated correctly.
736
- // Sample request ownership BEFORE enrichment. Enrichment fills absent fields from the
737
- // registry seed, after which "the client omitted this" and "the registry supplied it" are
738
- // indistinguishable — so a carry-over guard written as `prov.x === undefined` after this
739
- // call can never fire.
740
824
  const submittedContextWindow = Object.hasOwn(prov, "contextWindow");
741
825
  const submittedModelContextWindows = Object.hasOwn(prov, "modelContextWindows");
742
826
  const submittedModelAutoCompactTokenLimits = Object.hasOwn(prov, "modelAutoCompactTokenLimits");
743
827
  const submittedRequestPacing = Object.hasOwn(prov, "requestPacing");
744
- enrichProviderFromCatalog(name, prov);
745
- // Overwriting an existing provider must not drop its multi-key pool: carry it over, then
746
- // let the (possibly new) apiKey join the pool as the active entry.
747
- const existingPool = config.providers[name]?.apiKeyPool;
748
- if (existingPool && !prov.apiKeyPool && !prov.azureCredential) prov.apiKeyPool = existingPool;
749
- // The same rule applies to user-configured price overlays: the dashboard's
750
- // add/edit form does not send modelCosts, so an overwrite must not silently
751
- // erase hand-edited per-model prices from Logs/Usage estimates.
752
- const existingCosts = config.providers[name]?.modelCosts;
753
- if (existingCosts && !prov.modelCosts) prov.modelCosts = existingCosts;
754
- // And to the per-provider account-failover opt-out (#2568d). `ProviderPayload` has no
755
- // member for it either, so an add/edit save structurally cannot carry it — and dropping it
756
- // silently ENABLES rotation, because activation is presence-driven once the knob is gone.
757
- // An overwrite must not spend a second subscription account's quota as a side effect.
758
- const existingFailover = config.providers[name]?.oauthAccountFailover;
759
- if (existingFailover && !prov.oauthAccountFailover) prov.oauthAccountFailover = existingFailover;
760
- // ...and to hand-edited context windows. `ProviderPayload` (gui/src/provider-payload.ts)
761
- // has no member for either field, so the add/edit form structurally cannot send them:
762
- // absence in the request means "not carried", never "the user deleted it". Deletion goes
763
- // through PATCH with an explicit null (#1409).
764
- const existing = config.providers[name];
765
- if (!submittedRequestPacing && existing?.requestPacing) {
766
- prov.requestPacing = structuredClone(existing.requestPacing);
767
- }
768
- if (!submittedContextWindow && existing?.contextWindow !== undefined) {
769
- prov.contextWindow = existing.contextWindow;
770
- }
771
- if (existing?.modelContextWindows) {
772
- // When the client did send a map, its keys win and the user's other keys survive. When
773
- // it did not, the stored value is the user's map alone: merging the registry seed in
774
- // would persist seed keys into user config as a side effect of an unrelated save, and
775
- // router.ts already fills registry values beneath user entries at resolve time.
776
- prov.modelContextWindows = submittedModelContextWindows
777
- ? { ...existing.modelContextWindows, ...(prov.modelContextWindows ?? {}) }
778
- : { ...existing.modelContextWindows };
779
- }
780
- if (existing?.modelAutoCompactTokenLimits) {
781
- prov.modelAutoCompactTokenLimits = submittedModelAutoCompactTokenLimits
782
- ? { ...existing.modelAutoCompactTokenLimits, ...(prov.modelAutoCompactTokenLimits ?? {}) }
783
- : { ...existing.modelAutoCompactTokenLimits };
784
- }
785
- config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
786
- if (body.setDefault === true) config.defaultProvider = name;
787
- saveManagementConfig(deps, config);
828
+ const buildProvider = (existing: OcxProviderConfig | undefined): OcxProviderConfig => {
829
+ const next = structuredClone(prov);
830
+ // Native OpenAI capabilities are registry-owned runtime defaults. Enriching this exact
831
+ // canonical row adds supportsServiceTier to the document, which no longer equals the
832
+ // immutable forward seed and is correctly rejected by the write validator.
833
+ if (name !== "openai") enrichProviderFromCatalog(name, next);
834
+ const existingPool = existing?.apiKeyPool;
835
+ if (existingPool && !next.apiKeyPool && !next.azureCredential) next.apiKeyPool = existingPool;
836
+ if (typeof next.apiKey === "string" && !next.apiKey.trim()) delete next.apiKey;
837
+ if (!next.apiKey && existing?.apiKey && !next.azureCredential
838
+ && next.authMode !== "oauth" && next.authMode !== "forward") next.apiKey = existing.apiKey;
839
+ const existingCosts = existing?.modelCosts;
840
+ if (existingCosts && !next.modelCosts) next.modelCosts = existingCosts;
841
+ const existingFailover = existing?.oauthAccountFailover;
842
+ if (existingFailover && !next.oauthAccountFailover) next.oauthAccountFailover = existingFailover;
843
+ if (!submittedRequestPacing && existing?.requestPacing) next.requestPacing = structuredClone(existing.requestPacing);
844
+ if (!submittedContextWindow && existing?.contextWindow !== undefined) next.contextWindow = existing.contextWindow;
845
+ if (existing?.modelContextWindows) {
846
+ next.modelContextWindows = submittedModelContextWindows
847
+ ? { ...existing.modelContextWindows, ...(next.modelContextWindows ?? {}) }
848
+ : { ...existing.modelContextWindows };
849
+ }
850
+ if (existing?.modelAutoCompactTokenLimits) {
851
+ next.modelAutoCompactTokenLimits = submittedModelAutoCompactTokenLimits
852
+ ? { ...existing.modelAutoCompactTokenLimits, ...(next.modelAutoCompactTokenLimits ?? {}) }
853
+ : { ...existing.modelAutoCompactTokenLimits };
854
+ }
855
+ return stripRegistryOnlyStaticHeaders(name, next);
856
+ };
857
+ const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => {
858
+ const namespaceCollision = providerNamespaceCollisionError(fresh, name);
859
+ if (namespaceCollision) return { changed: false, value: { error: namespaceCollision, status: 409 } };
860
+ const next = buildProvider(fresh.providers[name]);
861
+ const keyCollision = reconcileSubmittedApiKey(next);
862
+ if (keyCollision) return { changed: false, value: { error: keyCollision, status: 409 } };
863
+ const providerError = providerManagementConfigError(name, next)
864
+ ?? providerServiceTierConfigError(name, next);
865
+ if (providerError) return { changed: false, value: { error: providerError, status: 400 } };
866
+ fresh.providers[name] = next;
867
+ if (body.setDefault === true) fresh.defaultProvider = name;
868
+ return { changed: true, value: { config: structuredClone(fresh) } };
869
+ });
870
+ if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config);
871
+ if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, outcome.value.status, req, config);
872
+ adoptCommittedConfig(config, outcome.value.config);
788
873
  reconcileLiveStateStores();
789
- if (prov.apiKey && prov.apiKeyPool) {
790
- const { addProviderApiKey } = await import("../../providers/api-keys");
791
- addProviderApiKey(config, name, prov.apiKey);
792
- }
793
874
  const { clearModelCache } = await import("../../codex/model-cache");
794
875
  clearModelCache(name);
795
876
  const catalogRefresh = await convergeCodexCatalog();
@@ -824,8 +905,17 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
824
905
  if (!provider || !isCanonicalOpenAiForwardProvider(provider)) {
825
906
  return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400);
826
907
  }
827
- config.providers.openai = { ...provider, codexAccountMode: mode };
828
- saveManagementConfig(deps, config);
908
+ const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => {
909
+ const provider = fresh.providers.openai;
910
+ if (!provider || !isCanonicalOpenAiForwardProvider(provider)) {
911
+ return { changed: false, value: { error: "provider openai must be the canonical built-in provider" } };
912
+ }
913
+ fresh.providers.openai = { ...provider, codexAccountMode: mode };
914
+ return { changed: true, value: { config: structuredClone(fresh) } };
915
+ });
916
+ if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config);
917
+ if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, 409);
918
+ adoptCommittedConfig(config, outcome.value.config);
829
919
  reconcileLiveStateStores();
830
920
  (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
831
921
  (deps.clearThreadAccountMap ?? clearThreadAccountMap)();
@@ -850,8 +940,17 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
850
940
  if (config.providers[name]!.disabled) {
851
941
  return jsonResponse({ error: "cannot set a disabled provider as default", code: "default_provider_disabled" }, 400);
852
942
  }
853
- config.defaultProvider = name;
854
- saveManagementConfig(deps, config);
943
+ const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => {
944
+ if (!hasOwnProvider(fresh.providers, name)) return { changed: false, value: { error: "unknown provider" } };
945
+ if (fresh.providers[name]!.disabled) {
946
+ return { changed: false, value: { error: "cannot set a disabled provider as default", code: "default_provider_disabled" } };
947
+ }
948
+ fresh.defaultProvider = name;
949
+ return { changed: true, value: { config: structuredClone(fresh) } };
950
+ });
951
+ if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config);
952
+ if ("error" in outcome.value) return jsonResponse(outcome.value, outcome.value.error === "unknown provider" ? 404 : 400);
953
+ adoptCommittedConfig(config, outcome.value.config);
855
954
  reconcileLiveStateStores();
856
955
  return jsonResponse({ success: true, name, defaultProvider: name });
857
956
  }
@@ -894,36 +993,41 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
894
993
  // concurrent PATCHes updating different fields/headers both survive instead of the
895
994
  // later save clobbering the earlier snapshot.
896
995
  let replayError: string | undefined;
897
- withConfigMutationLockSync(() => {
898
- const replay = applyProviderPatchFields(name, config.providers[name]!, rawBody, keys, config);
996
+ const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => {
997
+ const provider = fresh.providers[name];
998
+ if (!provider) return { changed: false, value: { error: "unknown provider" } };
999
+ const replay = applyProviderPatchFields(name, provider, rawBody, keys, fresh);
899
1000
  if ("error" in replay) {
900
1001
  replayError = replay.error;
901
- return;
1002
+ return { changed: false, value: { error: replay.error } };
902
1003
  }
903
1004
  if (replay.editorTouched && !pacingOnly) {
904
1005
  const syncError = canonicalBudgetOnly
905
- ? canonicalOpenAiBudgetPatchError(replay.next, rawBody, keys, config)
1006
+ ? canonicalOpenAiBudgetPatchError(replay.next, rawBody, keys, fresh)
906
1007
  : providerManagementConfigError(name, replay.next);
907
1008
  if (syncError) {
908
1009
  replayError = syncError;
909
- return;
1010
+ return { changed: false, value: { error: syncError } };
910
1011
  }
911
1012
  if (!canonicalBudgetOnly) {
912
1013
  const serviceTierError = providerServiceTierConfigError(name, replay.next);
913
1014
  if (serviceTierError) {
914
1015
  replayError = serviceTierError;
915
- return;
1016
+ return { changed: false, value: { error: serviceTierError } };
916
1017
  }
917
1018
  }
918
1019
  } else if (replay.enablingOpenAi && !isCanonicalOpenAiForwardProvider(replay.next)) {
919
1020
  replayError = "provider openai must be the canonical built-in provider";
920
- return;
1021
+ return { changed: false, value: { error: replayError } };
921
1022
  }
922
1023
  // A PATCH that managed headers owns the resulting block: the clear path restores
923
1024
  // registry static headers, so exact-match stripping must not erase them again.
924
- config.providers[name] = replay.headersTouched ? replay.next : stripRegistryOnlyStaticHeaders(name, replay.next);
925
- saveManagementConfig(deps, config);
1025
+ fresh.providers[name] = replay.headersTouched ? replay.next : stripRegistryOnlyStaticHeaders(name, replay.next);
1026
+ return { changed: true, value: { config: structuredClone(fresh) } };
926
1027
  });
1028
+ if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config);
1029
+ if ("error" in outcome.value) return jsonResponse({ error: outcome.value.error }, outcome.value.error === "unknown provider" ? 404 : 409);
1030
+ adoptCommittedConfig(config, outcome.value.config);
927
1031
  if (replayError !== undefined) return jsonResponse({ error: replayError }, 409);
928
1032
  reconcileLiveStateStores();
929
1033
  if (applied.editorTouched && !pacingOnly) {
@@ -1110,23 +1214,32 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1110
1214
  code: "last_provider",
1111
1215
  }, 409);
1112
1216
  }
1113
- const dependentCombos = Object.entries(config.combos ?? {})
1114
- .filter(([, combo]) => combo.targets.some(target => target.provider === name))
1115
- .map(([id]) => id)
1116
- .sort((a, b) => a.localeCompare(b));
1117
- if (dependentCombos.length > 0) {
1118
- return jsonResponse({
1119
- error: `cannot delete provider "${name}" while combos depend on it`,
1120
- code: "provider_has_dependent_combos",
1121
- combos: dependentCombos,
1122
- }, 409);
1217
+ const dependencyError = providerDependencyError(config, name);
1218
+ if (dependencyError) return jsonResponse(dependencyError, 409);
1219
+ const outcome = mutateManagementConfig<ProviderMutationValue>(deps, fresh => {
1220
+ if (!hasOwnProvider(fresh.providers, name)) return { changed: false, value: { error: "unknown provider" } };
1221
+ const fallbackDefault = name === fresh.defaultProvider
1222
+ ? Object.entries(fresh.providers)
1223
+ .find(([provider, providerConfig]) => provider !== name && providerConfig.disabled !== true)
1224
+ ?.[0]
1225
+ : undefined;
1226
+ if (name === fresh.defaultProvider && !fallbackDefault) {
1227
+ return { changed: false, value: { error: "cannot delete the default provider when no enabled replacement remains", code: "last_provider" } };
1228
+ }
1229
+ const dependencyError = providerDependencyError(fresh, name);
1230
+ if (dependencyError) return { changed: false, value: dependencyError };
1231
+ if (fallbackDefault) fresh.defaultProvider = fallbackDefault;
1232
+ delete fresh.providers[name];
1233
+ const droppedCustomModels = dropProviderCustomModels(fresh, name);
1234
+ setProviderContextCap(fresh, name, false);
1235
+ return { changed: true, value: { config: structuredClone(fresh), fallbackDefault, droppedCustomModels } };
1236
+ });
1237
+ if (outcome.status === "unavailable") return unavailableMutationResponse(outcome.reason, req, config);
1238
+ if ("error" in outcome.value) {
1239
+ const status = outcome.value.code?.startsWith("provider_has_dependent_") || outcome.value.code === "last_provider" ? 409 : 404;
1240
+ return jsonResponse(outcome.value, status);
1123
1241
  }
1124
- if (fallbackDefault) config.defaultProvider = fallbackDefault;
1125
- delete config.providers[name];
1126
- const { dropProviderCustomModels } = await import("../../providers/provider-id-rewrite");
1127
- const droppedCustomModels = dropProviderCustomModels(config, name);
1128
- setProviderContextCap(config, name, false);
1129
- saveManagementConfig(deps, config);
1242
+ adoptCommittedConfig(config, outcome.value.config);
1130
1243
  await replaceProviderAccountSet(name, null);
1131
1244
  reconcileLiveStateStores();
1132
1245
  const { clearModelCache: clearCache } = await import("../../codex/model-cache");
@@ -1134,8 +1247,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1134
1247
  const catalogRefresh = await convergeCodexCatalog();
1135
1248
  return jsonResponse({
1136
1249
  success: true,
1137
- ...(fallbackDefault ? { defaultProvider: fallbackDefault } : {}),
1138
- ...(droppedCustomModels > 0 ? { droppedCustomModels } : {}),
1250
+ ...(outcome.value.fallbackDefault ? { defaultProvider: outcome.value.fallbackDefault } : {}),
1251
+ droppedCustomModels: outcome.value.droppedCustomModels,
1139
1252
  catalogRefresh,
1140
1253
  });
1141
1254
  }