@yansigit/opencodex 2.36.1-dev.20260829.42 → 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 (51) hide show
  1. package/gui/dist/assets/{ApiKeys-C_cz6Rww.js → ApiKeys-LOOFiZfv.js} +1 -1
  2. package/gui/dist/assets/{Claude-ofGguLfF.js → Claude-BwKzpXe3.js} +1 -1
  3. package/gui/dist/assets/{CodexSet-fhGiVwxe.js → CodexSet-B5qi9KeE.js} +1 -1
  4. package/gui/dist/assets/{FileIntegrationPage-B-dI69Kn.js → FileIntegrationPage-B0sUef6W.js} +1 -1
  5. package/gui/dist/assets/{Grok-CgkBu_Sm.js → Grok-bsb4n-f4.js} +1 -1
  6. package/gui/dist/assets/{Integrations-CTWMNzkD.js → Integrations-CSMwFDvM.js} +2 -2
  7. package/gui/dist/assets/{IntegrationsOverview-BKZ0dZnj.js → IntegrationsOverview-CTORdKJA.js} +1 -1
  8. package/gui/dist/assets/{Logs-6r90C0x4.js → Logs-BWYbfELf.js} +1 -1
  9. package/gui/dist/assets/{Models-CpcS1zZr.js → Models-CexEtdT1.js} +1 -1
  10. package/gui/dist/assets/{NumberStepper-C70ttiJY.js → NumberStepper-BapSFQnW.js} +1 -1
  11. package/gui/dist/assets/{Providers-BROTzuSF.js → Providers-BwUFeAgA.js} +1 -1
  12. package/gui/dist/assets/{RestoreDialog-DR5vgFXB.js → RestoreDialog-CD8piq90.js} +1 -1
  13. package/gui/dist/assets/{Startup-BSoZDYbS.js → Startup-BgX731C2.js} +1 -1
  14. package/gui/dist/assets/{Storage-CUvBwbeB.js → Storage-Bgq7HphP.js} +1 -1
  15. package/gui/dist/assets/{Subagents-CYu3_DBt.js → Subagents-ChxItbeF.js} +1 -1
  16. package/gui/dist/assets/{Usage-BV36sd7V.js → Usage-BkylzP50.js} +1 -1
  17. package/gui/dist/assets/{data-surface-DhcMe0J_.js → data-surface-pCXChiBf.js} +1 -1
  18. package/gui/dist/assets/{index-DnuNKc0D.js → index-CNopOid3.js} +2 -2
  19. package/gui/dist/assets/{model-display-BPrQ7F3P.js → model-display-CiUpg8T9.js} +1 -1
  20. package/gui/dist/assets/{provider-payload-CMb37zWF.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/agent.ts +19 -2
  24. package/src/cli/config-command.ts +7 -3
  25. package/src/cli/init.ts +2 -2
  26. package/src/cli/provider.ts +58 -74
  27. package/src/cli/registry.ts +1 -1
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/convergence.ts +110 -15
  30. package/src/codex/inject.ts +2 -0
  31. package/src/codex/subagent-defaults.ts +1 -8
  32. package/src/codex/subagent-model-authority.ts +189 -0
  33. package/src/config.ts +209 -11
  34. package/src/generated/compatibility-version.json +35 -27
  35. package/src/oauth/index.ts +143 -82
  36. package/src/oauth/login-cli.ts +53 -11
  37. package/src/providers/alibaba-region-startup.ts +31 -11
  38. package/src/providers/api-keys.ts +58 -34
  39. package/src/providers/key-failover.ts +59 -46
  40. package/src/providers/model-rename-startup.ts +25 -7
  41. package/src/providers/openai-tier-startup.ts +38 -14
  42. package/src/server/index.ts +10 -0
  43. package/src/server/management/agent-settings-routes.ts +12 -0
  44. package/src/server/management/context.ts +10 -3
  45. package/src/server/management/logs-usage-routes.ts +19 -16
  46. package/src/server/management/model-routes.ts +284 -165
  47. package/src/server/management/oauth-account-routes.ts +93 -15
  48. package/src/server/management/provider-routes.ts +200 -87
  49. package/src/server/responses/collaboration.ts +1 -1
  50. package/src/storage/policy-input.ts +166 -0
  51. package/src/storage/policy.ts +59 -211
@@ -1,7 +1,7 @@
1
1
  import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types";
2
2
  import { parseCallbackInput } from "./callback-server";
3
3
  import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
4
- import { loadConfig, resolveEnvValue, saveConfig } from "../config";
4
+ import { initializePersistedConfigIfMissing, loadConfig, mutatePersistedConfig, resolveEnvValue } from "../config";
5
5
  import { maskEmail } from "../lib/privacy";
6
6
  import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
7
7
  import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent, normalizeAuthStoreBuffer, OAuthMutationBusyError } from "./store";
@@ -1023,42 +1023,107 @@ function migrateLegacyAntigravityStaticCatalog(config: OcxConfig): boolean {
1023
1023
  return true;
1024
1024
  }
1025
1025
 
1026
- export function reconcileOAuthProviders(config: OcxConfig): boolean {
1027
- let changed = migrateLegacyAntigravityStaticCatalog(config);
1028
- for (const [name, prov] of Object.entries(config.providers)) {
1026
+ interface OAuthReconcileProjection {
1027
+ config: OcxConfig;
1028
+ changed: boolean;
1029
+ touchedProviders: string[];
1030
+ touchedAntigravityVersion: boolean;
1031
+ }
1032
+
1033
+ function projectOAuthProviderReconciliation(config: OcxConfig): OAuthReconcileProjection {
1034
+ const projected = structuredClone(config);
1035
+ const touchedProviders = new Set<string>();
1036
+ const beforeAntigravity = JSON.stringify(projected.providers[GOOGLE_ANTIGRAVITY_PROVIDER]);
1037
+ const beforeAntigravityVersion = projected.googleAntigravityStaticCatalogVersion;
1038
+ let changed = migrateLegacyAntigravityStaticCatalog(projected);
1039
+ if (JSON.stringify(projected.providers[GOOGLE_ANTIGRAVITY_PROVIDER]) !== beforeAntigravity) {
1040
+ touchedProviders.add(GOOGLE_ANTIGRAVITY_PROVIDER);
1041
+ }
1042
+ const touchedAntigravityVersion = projected.googleAntigravityStaticCatalogVersion !== beforeAntigravityVersion;
1043
+
1044
+ for (const [name, prov] of Object.entries(projected.providers)) {
1045
+ const beforeProvider = JSON.stringify(prov);
1029
1046
  const def = OAUTH_PROVIDERS[name];
1030
1047
  if (name === "command-code" && isLegacyCommandCodeStaticCatalog(prov)) {
1031
1048
  // The former experimental preset was the exact three-model seed above. It was not a user
1032
1049
  // choice to disable discovery, so promote only that shape to the account live catalog.
1033
1050
  prov.liveModels = true;
1034
- changed = true;
1035
1051
  }
1036
- if (!def || prov.authMode !== "oauth") continue;
1037
- const preset = def.providerConfig;
1038
- for (const field of OAUTH_RECONCILE_FIELDS) {
1039
- if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue;
1040
- if (preset[field] !== undefined) {
1041
- prov[field] = cloneProviderField(preset[field]) as never;
1042
- } else {
1043
- delete prov[field];
1052
+ if (def && prov.authMode === "oauth") {
1053
+ const preset = def.providerConfig;
1054
+ for (const field of OAUTH_RECONCILE_FIELDS) {
1055
+ if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue;
1056
+ if (preset[field] !== undefined) {
1057
+ prov[field] = cloneProviderField(preset[field]) as never;
1058
+ } else {
1059
+ delete prov[field];
1060
+ }
1061
+ }
1062
+ if (prov.liveModels === undefined && preset.liveModels !== undefined) {
1063
+ prov.liveModels = preset.liveModels;
1064
+ }
1065
+ // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot).
1066
+ // Skip providers without a static preset `models` list: for live-discovery providers
1067
+ // (e.g. command-code OAuth) the account-scoped catalog is not enumerable here, so any
1068
+ // persisted defaultModel is a user selection and must not be overwritten by the seed.
1069
+ if (prov.defaultModel && preset.defaultModel && preset.models && preset.models.length > 0 && !(prov.models ?? []).includes(prov.defaultModel)) {
1070
+ prov.defaultModel = preset.defaultModel;
1044
1071
  }
1045
- changed = true;
1046
1072
  }
1047
- if (prov.liveModels === undefined && preset.liveModels !== undefined) {
1048
- prov.liveModels = preset.liveModels;
1073
+ if (JSON.stringify(prov) !== beforeProvider) {
1049
1074
  changed = true;
1075
+ touchedProviders.add(name);
1050
1076
  }
1051
- // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot).
1052
- // Skip providers without a static preset `models` list: for live-discovery providers
1053
- // (e.g. command-code OAuth) the account-scoped catalog is not enumerable here, so any
1054
- // persisted defaultModel is a user selection and must not be overwritten by the seed.
1055
- if (prov.defaultModel && preset.defaultModel && preset.models && preset.models.length > 0 && !(prov.models ?? []).includes(prov.defaultModel)) {
1056
- prov.defaultModel = preset.defaultModel;
1057
- changed = true;
1077
+ }
1078
+
1079
+ return {
1080
+ config: projected,
1081
+ changed,
1082
+ touchedProviders: [...touchedProviders],
1083
+ touchedAntigravityVersion,
1084
+ };
1085
+ }
1086
+
1087
+ function adoptOAuthReconciliation(config: OcxConfig, projection: OAuthReconcileProjection): void {
1088
+ for (const name of projection.touchedProviders) {
1089
+ const provider = projection.config.providers[name];
1090
+ if (provider) config.providers[name] = structuredClone(provider);
1091
+ else delete config.providers[name];
1092
+ }
1093
+ if (projection.touchedAntigravityVersion) {
1094
+ config.googleAntigravityStaticCatalogVersion = projection.config.googleAntigravityStaticCatalogVersion;
1095
+ }
1096
+ }
1097
+
1098
+ function withOAuthReconciliationTouchedKeys(
1099
+ projection: OAuthReconcileProjection,
1100
+ required: OAuthReconcileProjection,
1101
+ ): OAuthReconcileProjection {
1102
+ return {
1103
+ ...projection,
1104
+ touchedProviders: [...new Set([...projection.touchedProviders, ...required.touchedProviders])],
1105
+ touchedAntigravityVersion: projection.touchedAntigravityVersion || required.touchedAntigravityVersion,
1106
+ };
1107
+ }
1108
+
1109
+ export function reconcileOAuthProviders(config: OcxConfig, persist = true): boolean {
1110
+ const projection = projectOAuthProviderReconciliation(config);
1111
+ if (!projection.changed) return false;
1112
+ if (!persist) {
1113
+ adoptOAuthReconciliation(config, projection);
1114
+ return true;
1115
+ }
1116
+ if (persist) {
1117
+ const outcome = mutatePersistedConfig(fresh => {
1118
+ const next = projectOAuthProviderReconciliation(fresh);
1119
+ if (next.changed) adoptOAuthReconciliation(fresh, next);
1120
+ return { changed: next.changed, value: next };
1121
+ });
1122
+ if (outcome.status !== "unavailable") {
1123
+ adoptOAuthReconciliation(config, withOAuthReconciliationTouchedKeys(outcome.value, projection));
1058
1124
  }
1059
1125
  }
1060
- if (changed) saveConfig(config);
1061
- return changed;
1126
+ return true;
1062
1127
  }
1063
1128
 
1064
1129
  /** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
@@ -1091,24 +1156,19 @@ function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["a
1091
1156
  return pool.length > 0 ? pool : undefined;
1092
1157
  }
1093
1158
 
1094
- /**
1095
- * Add/refresh an OAuth provider's config entry on a config object (does not persist).
1096
- *
1097
- * Providers whose registry entry sets `allowKeyAuthOverride` (xai, github-copilot) can be
1098
- * billed through a stored API key instead of the OAuth login (router.ts honors
1099
- * `authMode: "key"` for them). A blind preset overwrite here deletes `apiKey`/`apiKeyPool`
1100
- * on every OAuth login, silently destroying the stored key and forcing a re-paste — and it
1101
- * flips billing back to the subscription without the user asking. Carry the key fields over
1102
- * and keep key billing while usable key material remains and the user was not explicitly on
1103
- * oauth. If the final key was removed and only the old key mode remains, let the OAuth
1104
- * preset restore `authMode: "oauth"` so the newly saved OAuth credential can be used.
1105
- *
1106
- * After preservation, `apiKey` always has exactly one matching pool entry (inserting via the
1107
- * same content-derived id as the API-key manager when the active key was missing from the
1108
- * pool). Key mode reflects stored user intent (explicit `"key"` or omitted mode with safe
1109
- * key material) — never whether the login CLI process can resolve an env reference. Env-backed
1110
- * availability is decided at proxy routing time in `router.ts`.
1111
- */
1159
+ const OAUTH_LOGIN_OWNED_PROVIDER_FIELDS = [
1160
+ "adapter",
1161
+ "baseUrl",
1162
+ "authMode",
1163
+ "headers",
1164
+ "apiKeyTransport",
1165
+ "responsesPath",
1166
+ "tlsProfile",
1167
+ "googleMode",
1168
+ "keyOptional",
1169
+ ] as const satisfies readonly (keyof OcxProviderConfig)[];
1170
+
1171
+ /** Add/refresh only an OAuth provider's login-owned config fields (does not persist). */
1112
1172
  export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1113
1173
  if (provider === "chatgpt") return;
1114
1174
  const def = OAUTH_PROVIDERS[provider];
@@ -1117,35 +1177,16 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1117
1177
  const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, provider);
1118
1178
  if (namespaceCollision) throw new Error(namespaceCollision);
1119
1179
  const existing = config.providers[provider];
1120
- const next: OcxProviderConfig = { ...def.providerConfig };
1121
- // `liveModels` is a user-facing provider toggle. Preserve either explicit setting across login;
1122
- // Antigravity's CCA discovery now uses its real RPC, so legacy `true` remains a valid choice.
1123
- if (typeof existing?.liveModels === "boolean" && !isLegacyCommandCodeStaticCatalog(existing)) {
1124
- next.liveModels = existing.liveModels;
1125
- }
1126
- // The Command Code protocol-version pin is an operator compatibility control. A re-login,
1127
- // add-account, or reauth rebuilds the row from the preset, which has no version; carry the
1128
- // existing pin so authentication changes do not silently revert the documented control.
1129
- if (existing?.commandCodeVersion !== undefined) {
1130
- next.commandCodeVersion = existing.commandCodeVersion;
1131
- }
1132
- if (existing?.projectContext !== undefined) {
1133
- next.projectContext = existing.projectContext;
1134
- }
1135
- // User-configured price overlays are operator data, not preset state; a
1136
- // re-login, add-account, or reauth must not silently drop them from the
1137
- // Logs/Usage estimates.
1138
- if (existing?.modelCosts !== undefined) {
1139
- next.modelCosts = existing.modelCosts;
1140
- }
1141
- // The per-provider account-failover opt-out is operator intent about SPENDING, and the login
1142
- // path is exactly where losing it does damage: adding a second account both rebuilds this row
1143
- // from the preset and creates the 2-account quorum that turns presence-driven rotation on
1144
- // (#2568d). Dropping the opt-out here would enable the thing the operator switched off, at the
1145
- // moment they were doing something unrelated.
1146
- if (existing?.oauthAccountFailover !== undefined) {
1147
- next.oauthAccountFailover = existing.oauthAccountFailover;
1180
+ const next: OcxProviderConfig = structuredClone(existing ?? def.providerConfig);
1181
+ for (const field of OAUTH_LOGIN_OWNED_PROVIDER_FIELDS) {
1182
+ const value = def.providerConfig[field];
1183
+ if (value === undefined) delete next[field];
1184
+ else next[field] = structuredClone(value) as never;
1148
1185
  }
1186
+ // OAuth-only providers must never retain credentials for a different auth mechanism.
1187
+ delete next.apiKey;
1188
+ delete next.apiKeyPool;
1189
+ delete next.azureCredential;
1149
1190
  if (existing && getProviderRegistryEntry(provider)?.allowKeyAuthOverride === true) {
1150
1191
  // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
1151
1192
  let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
@@ -1160,7 +1201,9 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1160
1201
  // Keep routing and listProviderApiKeys in sync: never leave a hidden active key that
1161
1202
  // is absent from the pool (listing would fall back to pool[0] as "active").
1162
1203
  if (!pool.some(entry => entry.key === storedApiKey)) {
1163
- pool.push({ id: apiKeyPoolEntryId(storedApiKey), key: storedApiKey });
1204
+ const id = apiKeyPoolEntryId(storedApiKey);
1205
+ if (pool.some(entry => entry.id === id)) throw new Error("API-key pool ID collision");
1206
+ pool.push({ id, key: storedApiKey });
1164
1207
  }
1165
1208
  next.apiKey = storedApiKey;
1166
1209
  next.apiKeyPool = pool;
@@ -1175,7 +1218,8 @@ interface RunLoginDeps {
1175
1218
  saveCredential?: typeof saveCredential;
1176
1219
  saveAccountCredential?: typeof saveAccountCredential;
1177
1220
  loadConfig?: typeof loadConfig;
1178
- saveConfig?: typeof saveConfig;
1221
+ mutatePersistedConfig?: typeof mutatePersistedConfig;
1222
+ initializePersistedConfigIfMissing?: typeof initializePersistedConfigIfMissing;
1179
1223
  settleKiroLoginTransaction?: typeof settleKiroLoginTransaction;
1180
1224
  removeAccount?: typeof removeAccount;
1181
1225
  setActiveAccount?: typeof setActiveAccount;
@@ -1210,7 +1254,8 @@ export async function runLogin(
1210
1254
  const def = OAUTH_PROVIDERS[provider];
1211
1255
  if (!def) throw new UnsupportedOAuthProviderError(provider);
1212
1256
  const loadLatestConfig = deps.loadConfig ?? loadConfig;
1213
- const saveLatestConfig = deps.saveConfig ?? saveConfig;
1257
+ const mutateLatestConfig = deps.mutatePersistedConfig ?? mutatePersistedConfig;
1258
+ const initializeLatestConfig = deps.initializePersistedConfigIfMissing ?? initializePersistedConfigIfMissing;
1214
1259
  if (provider !== "chatgpt") {
1215
1260
  const preflightConfig = loadLatestConfig();
1216
1261
  const namespaceCollision = codexAccountNamespaceProviderCollisionError(
@@ -1265,16 +1310,32 @@ export async function runLogin(
1265
1310
  if (provider !== "chatgpt") {
1266
1311
  // Re-run against post-credential state so same-provider API-key additions, removals,
1267
1312
  // and active-key switches survive. A late namespace claim wins over provider creation.
1268
- const latestConfig = loadLatestConfig();
1269
- const lateCollision = codexAccountNamespaceProviderCollisionError(
1270
- latestConfig.codexAccountNamespaces,
1271
- provider,
1272
- );
1273
- if (lateCollision) {
1313
+ const publish = () => mutateLatestConfig<{ error: string } | { config: OcxConfig }>(fresh => {
1314
+ const lateCollision = codexAccountNamespaceProviderCollisionError(
1315
+ fresh.codexAccountNamespaces,
1316
+ provider,
1317
+ );
1318
+ if (lateCollision) return { changed: false, value: { error: lateCollision } };
1319
+ upsertOAuthProvider(fresh, provider);
1320
+ return { changed: true, value: { config: structuredClone(fresh) } };
1321
+ });
1322
+ let outcome = publish();
1323
+ let published = false;
1324
+ if (outcome.status === "unavailable" && outcome.reason === "missing") {
1325
+ const initial = loadLatestConfig();
1326
+ const lateCollision = codexAccountNamespaceProviderCollisionError(
1327
+ initial.codexAccountNamespaces,
1328
+ provider,
1329
+ );
1330
+ if (lateCollision) throw new OAuthProviderPublicationError();
1331
+ upsertOAuthProvider(initial, provider);
1332
+ const initialized = initializeLatestConfig(initial);
1333
+ published = initialized === "created";
1334
+ if (initialized === "exists") outcome = publish();
1335
+ }
1336
+ if (!published && (outcome.status === "unavailable" || "error" in outcome.value)) {
1274
1337
  throw new OAuthProviderPublicationError();
1275
1338
  }
1276
- upsertOAuthProvider(latestConfig, provider);
1277
- saveLatestConfig(latestConfig);
1278
1339
  }
1279
1340
  } catch (error) {
1280
1341
  const errors: unknown[] = [error];
@@ -1,6 +1,6 @@
1
1
  import * as readline from "node:readline";
2
2
  import { openUrl } from "../lib/open-url";
3
- import { loadConfig, saveConfig } from "../config";
3
+ import { initializePersistedConfigIfMissing, loadConfig, mutatePersistedConfig } from "../config";
4
4
  import { findLiveProxy } from "../server/proxy-liveness";
5
5
  import {
6
6
  requestBoundLocalProviderReload,
@@ -11,6 +11,7 @@ import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginP
11
11
  import type { OcxConfig, OcxProviderConfig } from "../types";
12
12
  import { configuredAdminToken } from "../lib/admin-secrets";
13
13
  import { codexAccountNamespaceProviderCollisionError } from "../codex/account-namespace-match";
14
+ import { apiKeyPoolEntryId } from "../providers/api-keys";
14
15
 
15
16
  const LIVE_RELOAD_PROVIDERS = new Set<string>([
16
17
  ...listOAuthProviders(),
@@ -121,7 +122,7 @@ async function handleAiStudioLogin(): Promise<void> {
121
122
 
122
123
  const config = loadConfig();
123
124
  if (!config.providers["google-aistudio"]) {
124
- config.providers["google-aistudio"] = {
125
+ await commitKeyLoginProvider(config, "google-aistudio", {
125
126
  adapter: "google",
126
127
  googleMode: "ai-studio-web",
127
128
  baseUrl: "https://alkalimakersuite-pa.clients6.google.com",
@@ -129,8 +130,7 @@ async function handleAiStudioLogin(): Promise<void> {
129
130
  liveModels: false,
130
131
  defaultModel: "gemini-3.7-flash",
131
132
  models: ["gemini-3.7-flash", "gemini-3.1-pro-preview", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-3.5-flash"],
132
- };
133
- saveConfig(config);
133
+ });
134
134
  console.log("\n ✓ Configured 'google-aistudio' in ~/.opencodex/config.json");
135
135
  }
136
136
 
@@ -200,10 +200,28 @@ export function mergeKeyLoginProviderRow(
200
200
  provider: OcxProviderConfig,
201
201
  existing: OcxProviderConfig | undefined,
202
202
  ): OcxProviderConfig {
203
- return {
204
- ...provider,
205
- ...(existing?.modelCosts !== undefined ? { modelCosts: existing.modelCosts } : {}),
206
- };
203
+ if (!existing) return structuredClone(provider);
204
+ const merged = structuredClone(existing);
205
+ merged.adapter = provider.adapter;
206
+ merged.baseUrl = provider.baseUrl;
207
+ if (provider.authMode !== undefined) merged.authMode = provider.authMode;
208
+ else delete merged.authMode;
209
+ delete merged.azureCredential;
210
+ if (provider.apiKeyTransport !== undefined) merged.apiKeyTransport = provider.apiKeyTransport;
211
+ if (provider.apiKey) {
212
+ const pool = merged.apiKeyPool ?? (merged.apiKey
213
+ ? [{ id: apiKeyPoolEntryId(merged.apiKey), key: merged.apiKey }]
214
+ : []);
215
+ const existingKey = pool.find(entry => entry.key === provider.apiKey);
216
+ if (!existingKey) {
217
+ const id = apiKeyPoolEntryId(provider.apiKey);
218
+ if (pool.some(entry => entry.id === id)) throw new Error("API-key pool ID collision");
219
+ pool.push({ id, key: provider.apiKey, addedAt: Date.now() });
220
+ }
221
+ if (pool.length > 0) merged.apiKeyPool = pool;
222
+ merged.apiKey = provider.apiKey;
223
+ }
224
+ return merged;
207
225
  }
208
226
 
209
227
  /**
@@ -219,9 +237,33 @@ export async function commitKeyLoginProvider(
219
237
  provider: OcxProviderConfig,
220
238
  onLiveReload?: (result: LocalProviderReloadResult | null) => void,
221
239
  ): Promise<OcxProviderConfig> {
222
- const mergedProvider = mergeKeyLoginProviderRow(provider, config.providers[name]);
223
- config.providers[name] = mergedProvider;
224
- saveConfig(config);
240
+ let mergedProvider = mergeKeyLoginProviderRow(provider, config.providers[name]);
241
+ const mutate = () => mutatePersistedConfig(fresh => {
242
+ const collision = codexAccountNamespaceProviderCollisionError(fresh.codexAccountNamespaces, name);
243
+ if (collision) throw new Error(collision);
244
+ mergedProvider = mergeKeyLoginProviderRow(provider, fresh.providers[name]);
245
+ fresh.providers[name] = mergedProvider;
246
+ return { changed: true, value: { config: structuredClone(fresh), provider: structuredClone(mergedProvider) } };
247
+ });
248
+ let outcome = mutate();
249
+ if (outcome.status === "unavailable" && outcome.reason === "missing") {
250
+ const initial = structuredClone(config);
251
+ const collision = codexAccountNamespaceProviderCollisionError(initial.codexAccountNamespaces, name);
252
+ if (collision) throw new Error(collision);
253
+ mergedProvider = mergeKeyLoginProviderRow(provider, initial.providers[name]);
254
+ initial.providers[name] = mergedProvider;
255
+ const initialized = initializePersistedConfigIfMissing(initial);
256
+ if (initialized === "invalid") throw new Error("config is invalid");
257
+ outcome = initialized === "created"
258
+ ? { status: "committed", value: { config: initial, provider: structuredClone(mergedProvider) } }
259
+ : mutate();
260
+ }
261
+ if (outcome.status === "unavailable") throw new Error(outcome.reason === "conflict"
262
+ ? "config changed while saving provider; retry"
263
+ : `config is ${outcome.reason}`);
264
+ for (const key of Object.keys(config)) delete (config as unknown as Record<string, unknown>)[key];
265
+ Object.assign(config, outcome.value.config);
266
+ mergedProvider = outcome.value.provider;
225
267
  // Evaluate the reload BEFORE the optional call: `onLiveReload?.(await ...)` short-circuits
226
268
  // the whole argument list when no callback is supplied, so the reload would never fire for
227
269
  // callers that do not care about the outcome.
@@ -1,4 +1,4 @@
1
- import { saveConfig } from "../config";
1
+ import { mutatePersistedConfig } from "../config";
2
2
  import { backupConfigBeforeAlibabaRegionMigration } from "./alibaba-region-backup";
3
3
  import { projectAlibabaRegionMigration } from "./alibaba-region-migration";
4
4
  import type { OcxConfig } from "../types";
@@ -6,7 +6,7 @@ import type { OcxConfig } from "../types";
6
6
  export interface AlibabaRegionStartupDeps {
7
7
  project: typeof projectAlibabaRegionMigration;
8
8
  backup: () => void;
9
- save: (config: OcxConfig) => void;
9
+ save?: (config: OcxConfig) => void;
10
10
  }
11
11
 
12
12
  /**
@@ -22,15 +22,35 @@ export function runAlibabaRegionStartupMigration(
22
22
  deps: AlibabaRegionStartupDeps = {
23
23
  project: projectAlibabaRegionMigration,
24
24
  backup: () => { backupConfigBeforeAlibabaRegionMigration(); },
25
- save: saveConfig,
26
25
  },
27
26
  ): OcxConfig {
28
- const projection = deps.project(config);
29
- // Warnings are emitted even on a no-op: the collision case IS the warning.
30
- for (const warning of projection.warnings) console.warn(`[alibaba-region-migration] ${warning}`);
31
- if (!projection.changed) return projection.config;
32
- // Strictly before the save: the snapshot must describe the config as it was.
33
- deps.backup();
34
- deps.save(projection.config);
35
- return projection.config;
27
+ if (deps.save) {
28
+ const projection = deps.project(config);
29
+ if (!projection.changed) {
30
+ for (const warning of projection.warnings) console.warn(`[alibaba-region-migration] ${warning}`);
31
+ return projection.config;
32
+ }
33
+ deps.backup();
34
+ deps.save(projection.config);
35
+ for (const warning of projection.warnings) console.warn(`[alibaba-region-migration] ${warning}`);
36
+ return projection.config;
37
+ }
38
+
39
+ let previousSnapshot: string | undefined;
40
+ const outcome = mutatePersistedConfig(fresh => {
41
+ const snapshot = JSON.stringify(fresh);
42
+ const next = deps.project(fresh);
43
+ // The callback is repeated after freshness validation. Back up only the
44
+ // confirmed preimage that is about to be committed.
45
+ if (next.changed && snapshot === previousSnapshot) deps.backup();
46
+ previousSnapshot = snapshot;
47
+ if (next.changed) {
48
+ for (const key of Object.keys(fresh)) delete (fresh as unknown as Record<string, unknown>)[key];
49
+ Object.assign(fresh, next.config);
50
+ }
51
+ return { changed: next.changed, value: next };
52
+ });
53
+ if (outcome.status === "unavailable") return config;
54
+ for (const warning of outcome.value.warnings) console.warn(`[alibaba-region-migration] ${warning}`);
55
+ return outcome.value.config;
36
56
  }
@@ -7,7 +7,7 @@
7
7
  * A provider with a legacy bare `apiKey` is seeded into a one-entry pool on first touch.
8
8
  */
9
9
  import { createHash } from "node:crypto";
10
- import { saveConfigPreservingClaudeCode } from "../config";
10
+ import { mutatePersistedConfig } from "../config";
11
11
  import { isAzureIdentityProvider } from "../config/provider-validation";
12
12
  import type { OcxConfig, OcxProviderConfig } from "../types";
13
13
 
@@ -62,6 +62,23 @@ function activeEntryId(provider: OcxProviderConfig): string | null {
62
62
  return (pool.find(e => e.key === provider.apiKey) ?? pool[0]!).id;
63
63
  }
64
64
 
65
+ function mutateProvider<T>(
66
+ config: OcxConfig,
67
+ name: string,
68
+ mutate: (provider: OcxProviderConfig) => T | null,
69
+ ): T | null {
70
+ const outcome = mutatePersistedConfig(fresh => {
71
+ const provider = fresh.providers[name];
72
+ if (!provider || !isKeyAuthProvider(provider)) return { changed: false, value: null };
73
+ const before = JSON.stringify(provider);
74
+ const value = mutate(provider);
75
+ return { changed: value !== null && JSON.stringify(provider) !== before, value: value === null ? null : { provider, value } };
76
+ });
77
+ if (outcome.status === "unavailable" || outcome.value === null) return null;
78
+ config.providers[name] = structuredClone(outcome.value.provider);
79
+ return outcome.value.value;
80
+ }
81
+
65
82
  export function listProviderApiKeys(config: OcxConfig, name: string): { activeId: string | null; keys: ProviderApiKeyInfo[] } {
66
83
  const provider = config.providers[name];
67
84
  if (!provider || !isKeyAuthProvider(provider)) return { activeId: null, keys: [] };
@@ -86,56 +103,63 @@ export function addProviderApiKey(config: OcxConfig, name: string, key: string,
86
103
  if (typeof key !== "string" || !key.trim()) return { error: "key is required" };
87
104
  const trimmed = sanitizeApiKeyValue(key);
88
105
  if (!trimmed) return { error: "key must not include line breaks" };
89
- const pool = ensurePool(provider);
90
- const id = apiKeyPoolEntryId(trimmed);
91
- const existing = pool.find(e => e.id === id);
92
- if (existing) {
93
- if (label?.trim()) existing.label = label.trim();
94
- } else {
106
+ const saved = mutateProvider(config, name, fresh => {
107
+ const pool = ensurePool(fresh);
108
+ const existing = pool.find(entry => entry.key === trimmed);
109
+ if (existing) {
110
+ if (label?.trim()) existing.label = label.trim();
111
+ fresh.apiKey = trimmed;
112
+ return { id: existing.id };
113
+ }
114
+ const id = apiKeyPoolEntryId(trimmed);
115
+ if (pool.some(entry => entry.id === id)) return { error: "key id collision" };
95
116
  pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
96
- }
97
- provider.apiKey = trimmed;
98
- saveConfigPreservingClaudeCode(config);
99
- return { id };
117
+ fresh.apiKey = trimmed;
118
+ return { id };
119
+ });
120
+ return saved === null ? { error: "config is unavailable" } : saved;
100
121
  }
101
122
 
102
123
  /** Switch the ACTIVE key (mirrors into `provider.apiKey`). Persists config. */
103
124
  export function setActiveProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
104
125
  const provider = config.providers[name];
105
126
  if (!provider || !isKeyAuthProvider(provider)) return false;
106
- const entry = ensurePool(provider).find(e => e.id === id);
107
- if (!entry) return false;
108
- provider.apiKey = entry.key;
109
- saveConfigPreservingClaudeCode(config);
110
- return true;
127
+ return mutateProvider(config, name, fresh => {
128
+ const entry = ensurePool(fresh).find(candidate => candidate.id === id);
129
+ if (!entry) return null;
130
+ fresh.apiKey = entry.key;
131
+ return true;
132
+ }) === true;
111
133
  }
112
134
 
113
135
  /** Rename a key slot without changing its id, secret, or active routing state. */
114
136
  export function setProviderApiKeyLabel(config: OcxConfig, name: string, id: string, label: string | undefined): boolean {
115
137
  const provider = config.providers[name];
116
138
  if (!provider || !isKeyAuthProvider(provider)) return false;
117
- const entry = ensurePool(provider).find(e => e.id === id);
118
- if (!entry) return false;
119
- if (label) entry.label = label;
120
- else delete entry.label;
121
- saveConfigPreservingClaudeCode(config);
122
- return true;
139
+ return mutateProvider(config, name, fresh => {
140
+ const entry = ensurePool(fresh).find(candidate => candidate.id === id);
141
+ if (!entry) return null;
142
+ if (label) entry.label = label;
143
+ else delete entry.label;
144
+ return true;
145
+ }) === true;
123
146
  }
124
147
 
125
148
  /** Remove one key; removing the active one promotes the first remaining. Persists config. */
126
149
  export function removeProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
127
150
  const provider = config.providers[name];
128
151
  if (!provider || !isKeyAuthProvider(provider)) return false;
129
- const pool = ensurePool(provider);
130
- const entry = pool.find(e => e.id === id);
131
- if (!entry) return false;
132
- provider.apiKeyPool = pool.filter(e => e.id !== id);
133
- if (provider.apiKey === entry.key) {
134
- const next = provider.apiKeyPool[0];
135
- if (next) provider.apiKey = next.key;
136
- else delete provider.apiKey;
137
- }
138
- if (provider.apiKeyPool.length === 0) delete provider.apiKeyPool;
139
- saveConfigPreservingClaudeCode(config);
140
- return true;
152
+ return mutateProvider(config, name, fresh => {
153
+ const pool = ensurePool(fresh);
154
+ const entry = pool.find(candidate => candidate.id === id);
155
+ if (!entry) return null;
156
+ fresh.apiKeyPool = pool.filter(candidate => candidate.id !== id);
157
+ if (fresh.apiKey === entry.key) {
158
+ const next = fresh.apiKeyPool[0];
159
+ if (next) fresh.apiKey = next.key;
160
+ else delete fresh.apiKey;
161
+ }
162
+ if (fresh.apiKeyPool.length === 0) delete fresh.apiKeyPool;
163
+ return true;
164
+ }) === true;
141
165
  }