@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.
- package/gui/dist/assets/{ApiKeys-DAuYQsGJ.js → ApiKeys-LOOFiZfv.js} +1 -1
- package/gui/dist/assets/{Claude-bN_80RT8.js → Claude-BwKzpXe3.js} +1 -1
- package/gui/dist/assets/{CodexSet-D3dWX_ay.js → CodexSet-B5qi9KeE.js} +1 -1
- package/gui/dist/assets/{FileIntegrationPage-BGyzkPs2.js → FileIntegrationPage-B0sUef6W.js} +1 -1
- package/gui/dist/assets/{Grok-CuaKd1TT.js → Grok-bsb4n-f4.js} +1 -1
- package/gui/dist/assets/{Integrations-C7NGQoxR.js → Integrations-CSMwFDvM.js} +2 -2
- package/gui/dist/assets/{IntegrationsOverview-DjVorYO5.js → IntegrationsOverview-CTORdKJA.js} +1 -1
- package/gui/dist/assets/{Logs-B21a-9xe.js → Logs-BWYbfELf.js} +1 -1
- package/gui/dist/assets/{Models-CN7Q3kuK.js → Models-CexEtdT1.js} +1 -1
- package/gui/dist/assets/{NumberStepper-BWg90tEs.js → NumberStepper-BapSFQnW.js} +1 -1
- package/gui/dist/assets/{Providers-CP_i-7LS.js → Providers-BwUFeAgA.js} +1 -1
- package/gui/dist/assets/{RestoreDialog-3M0NqCiD.js → RestoreDialog-CD8piq90.js} +1 -1
- package/gui/dist/assets/{Startup-BVPFoP2s.js → Startup-BgX731C2.js} +1 -1
- package/gui/dist/assets/{Storage-BdsG9ypz.js → Storage-Bgq7HphP.js} +1 -1
- package/gui/dist/assets/{Subagents-CguR_bGQ.js → Subagents-ChxItbeF.js} +1 -1
- package/gui/dist/assets/{Usage-C0kKi9_H.js → Usage-BkylzP50.js} +1 -1
- package/gui/dist/assets/{data-surface-BCycbObp.js → data-surface-pCXChiBf.js} +1 -1
- package/gui/dist/assets/{index-CU1jE0st.js → index-CNopOid3.js} +2 -2
- package/gui/dist/assets/{model-display-D2elXkuE.js → model-display-CiUpg8T9.js} +1 -1
- package/gui/dist/assets/{provider-payload-DfkXWE7v.js → provider-payload-b2jlS-On.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli/config-command.ts +7 -3
- package/src/cli/init.ts +2 -2
- package/src/cli/provider.ts +58 -74
- package/src/codex/convergence.ts +110 -15
- package/src/config.ts +209 -11
- package/src/generated/compatibility-version.json +24 -20
- package/src/oauth/index.ts +143 -82
- package/src/oauth/login-cli.ts +53 -11
- package/src/providers/alibaba-region-startup.ts +31 -11
- package/src/providers/api-keys.ts +58 -34
- package/src/providers/key-failover.ts +59 -46
- package/src/providers/model-rename-startup.ts +25 -7
- package/src/providers/openai-tier-startup.ts +38 -14
- package/src/server/index.ts +10 -0
- package/src/server/management/context.ts +10 -3
- package/src/server/management/logs-usage-routes.ts +19 -16
- package/src/server/management/model-routes.ts +284 -165
- package/src/server/management/oauth-account-routes.ts +93 -15
- package/src/server/management/provider-routes.ts +200 -87
- package/src/storage/policy-input.ts +166 -0
- package/src/storage/policy.ts +59 -211
package/src/cli/provider.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* show <name> Show provider config details (secrets masked)
|
|
9
9
|
* set-default <name> Change the default provider
|
|
10
10
|
*/
|
|
11
|
-
import { hasOwnProvider, isValidProviderName, loadConfig,
|
|
11
|
+
import { hasOwnProvider, isValidProviderName, loadConfig, mutatePersistedConfig, sanitizeModelCostsForDisplay } from "../config";
|
|
12
12
|
import { apiKeyTransportConfigError } from "../config/provider-validation";
|
|
13
13
|
import { hasHelpFlag } from "./help";
|
|
14
14
|
import { getProviderRegistryEntry, PROVIDER_REGISTRY } from "../providers/registry";
|
|
@@ -57,19 +57,26 @@ function maskSecret(value: string): string {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
// ---------------------------------------------------------------------------
|
|
60
|
-
// Validation helper
|
|
60
|
+
// Validation helper
|
|
61
61
|
// ---------------------------------------------------------------------------
|
|
62
62
|
|
|
63
|
-
function
|
|
63
|
+
function validateProviderConfig(config: ReturnType<typeof loadConfig>): void {
|
|
64
64
|
if (!config.providers || Object.keys(config.providers).length === 0) {
|
|
65
|
-
|
|
66
|
-
process.exit(1);
|
|
65
|
+
throw new Error("config would have no providers");
|
|
67
66
|
}
|
|
68
67
|
if (!hasOwnProvider(config.providers, config.defaultProvider)) {
|
|
69
|
-
|
|
70
|
-
|
|
68
|
+
throw new Error(`defaultProvider "${config.defaultProvider}" does not exist in providers`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mutateProviderConfig<T>(mutate: (config: ReturnType<typeof loadConfig>) => { changed: boolean; value: T }): T {
|
|
73
|
+
const outcome = mutatePersistedConfig(mutate);
|
|
74
|
+
if (outcome.status === "unavailable") {
|
|
75
|
+
throw new Error(outcome.reason === "conflict"
|
|
76
|
+
? "config changed while applying this provider update; retry"
|
|
77
|
+
: `config is ${outcome.reason}`);
|
|
71
78
|
}
|
|
72
|
-
|
|
79
|
+
return outcome.value;
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
// ---------------------------------------------------------------------------
|
|
@@ -154,19 +161,6 @@ async function handleAdd(args: string[]): Promise<void> {
|
|
|
154
161
|
const defaultModel = consumeFlagValue(restArgs, "--default-model");
|
|
155
162
|
rejectUnknownArgs(restArgs, ADD_USAGE);
|
|
156
163
|
|
|
157
|
-
const config = loadConfig();
|
|
158
|
-
|
|
159
|
-
const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
|
|
160
|
-
if (namespaceCollision) {
|
|
161
|
-
console.error(`Error: ${namespaceCollision}.`);
|
|
162
|
-
process.exit(1);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
if (hasOwnProvider(config.providers, name) && !force) {
|
|
166
|
-
console.error(`Provider "${name}" already exists. Use --force to overwrite.`);
|
|
167
|
-
process.exit(1);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
164
|
let provConfig: OcxProviderConfig;
|
|
171
165
|
const registryEntry = getProviderRegistryEntry(name);
|
|
172
166
|
|
|
@@ -211,18 +205,21 @@ async function handleAdd(args: string[]): Promise<void> {
|
|
|
211
205
|
provConfig.apiKeyTransport = apiKeyTransport;
|
|
212
206
|
}
|
|
213
207
|
|
|
214
|
-
const existingProvider = config.providers[name];
|
|
215
|
-
config.providers[name] = provConfig;
|
|
216
|
-
// A --force overwrite rotates the key/endpoint but must not drop a
|
|
217
|
-
// user-configured price overlay (same rule as the /api/providers path and
|
|
218
|
-
// the login paths); there is no explicit clear/replace flag yet.
|
|
219
|
-
if (existingProvider?.modelCosts !== undefined && provConfig.modelCosts === undefined) {
|
|
220
|
-
provConfig.modelCosts = existingProvider.modelCosts;
|
|
221
|
-
}
|
|
222
208
|
if (allowPrivateNetwork) provConfig.allowPrivateNetwork = true;
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
209
|
+
const saved = mutateProviderConfig(config => {
|
|
210
|
+
const namespaceCollision = codexAccountNamespaceProviderCollisionError(config.codexAccountNamespaces, name);
|
|
211
|
+
if (namespaceCollision) throw new Error(`${namespaceCollision}.`);
|
|
212
|
+
const existingProvider = config.providers[name];
|
|
213
|
+
if (existingProvider && !force) throw new Error(`Provider "${name}" already exists. Use --force to overwrite.`);
|
|
214
|
+
const next = structuredClone(provConfig);
|
|
215
|
+
// A --force overwrite rotates the key/endpoint but must not drop a user-configured price overlay.
|
|
216
|
+
if (existingProvider?.modelCosts !== undefined && next.modelCosts === undefined) next.modelCosts = existingProvider.modelCosts;
|
|
217
|
+
config.providers[name] = next;
|
|
218
|
+
if (setDefault) config.defaultProvider = name;
|
|
219
|
+
validateProviderConfig(config);
|
|
220
|
+
return { changed: JSON.stringify(existingProvider) !== JSON.stringify(next) || (setDefault && config.defaultProvider === name), value: { provider: next, defaultProvider: config.defaultProvider } };
|
|
221
|
+
});
|
|
222
|
+
provConfig = saved.provider;
|
|
226
223
|
|
|
227
224
|
if (wantsJson) {
|
|
228
225
|
console.log(JSON.stringify({
|
|
@@ -231,7 +228,7 @@ async function handleAdd(args: string[]): Promise<void> {
|
|
|
231
228
|
adapter: provConfig.adapter,
|
|
232
229
|
baseUrl: provConfig.baseUrl,
|
|
233
230
|
defaultModel: provConfig.defaultModel ?? null,
|
|
234
|
-
isDefault:
|
|
231
|
+
isDefault: saved.defaultProvider === name,
|
|
235
232
|
source: registryEntry ? "registry" : "custom",
|
|
236
233
|
needsSync: true,
|
|
237
234
|
}, null, 2));
|
|
@@ -285,42 +282,32 @@ function handleRemove(args: string[]): void {
|
|
|
285
282
|
}
|
|
286
283
|
rejectUnknownArgs(restArgs.slice(1), "Usage: ocx provider remove <name> [--json]");
|
|
287
284
|
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
.map(([id]) => id)
|
|
307
|
-
.sort();
|
|
308
|
-
if (dependentCombos.length > 0) {
|
|
309
|
-
console.error(`Cannot remove "${name}" — combo(s) depend on it: ${dependentCombos.join(", ")}`);
|
|
310
|
-
process.exit(1);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
delete config.providers[name];
|
|
314
|
-
const droppedCustomModels = dropProviderCustomModels(config, name);
|
|
315
|
-
validateAndSave(config);
|
|
285
|
+
const saved = mutateProviderConfig(config => {
|
|
286
|
+
if (!hasOwnProvider(config.providers, name)) throw new Error(`Provider "${name}" is not configured.`);
|
|
287
|
+
if (name === config.defaultProvider) throw new Error(`Cannot remove "${name}" — it is the default provider. Change the default first: ocx provider set-default <other>`);
|
|
288
|
+
if (Object.keys(config.providers).length <= 1) throw new Error("Cannot remove the last provider.");
|
|
289
|
+
const dependentCombos = Object.entries(config.combos ?? {})
|
|
290
|
+
.filter(([, combo]) => combo.targets.some(target => target.provider === name))
|
|
291
|
+
.map(([id]) => id).sort();
|
|
292
|
+
if (dependentCombos.length > 0) throw new Error(`Cannot remove "${name}" — combo(s) depend on it: ${dependentCombos.join(", ")}`);
|
|
293
|
+
const dependentProfiles = Object.entries(config.routingProfiles ?? {})
|
|
294
|
+
.filter(([, profile]) => profile.candidates.some(candidate => candidate.provider === name))
|
|
295
|
+
.map(([id]) => id).sort();
|
|
296
|
+
if (dependentProfiles.length > 0) throw new Error(`Cannot remove "${name}" — routing profile(s) depend on it: ${dependentProfiles.join(", ")}`);
|
|
297
|
+
delete config.providers[name];
|
|
298
|
+
const droppedCustomModels = dropProviderCustomModels(config, name);
|
|
299
|
+
validateProviderConfig(config);
|
|
300
|
+
return { changed: true, value: { droppedCustomModels, providers: Object.keys(config.providers), defaultProvider: config.defaultProvider } };
|
|
301
|
+
});
|
|
302
|
+
const { droppedCustomModels } = saved;
|
|
316
303
|
|
|
317
304
|
|
|
318
305
|
if (wantsJson) {
|
|
319
306
|
console.log(JSON.stringify({
|
|
320
307
|
action: "removed",
|
|
321
308
|
provider: name,
|
|
322
|
-
remainingProviders:
|
|
323
|
-
defaultProvider:
|
|
309
|
+
remainingProviders: saved.providers,
|
|
310
|
+
defaultProvider: saved.defaultProvider,
|
|
324
311
|
needsSync: true,
|
|
325
312
|
...(droppedCustomModels > 0 ? { droppedCustomModels } : {}),
|
|
326
313
|
}, null, 2));
|
|
@@ -396,13 +383,14 @@ function handleSetDefault(args: string[]): void {
|
|
|
396
383
|
}
|
|
397
384
|
rejectUnknownArgs(restArgs.slice(1), "Usage: ocx provider set-default <name> [--json]");
|
|
398
385
|
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
386
|
+
const changed = mutateProviderConfig(config => {
|
|
387
|
+
if (!hasOwnProvider(config.providers, name)) throw new Error(`Provider "${name}" is not configured. Add it first: ocx provider add ${name}`);
|
|
388
|
+
if (config.defaultProvider === name) return { changed: false, value: false };
|
|
389
|
+
config.defaultProvider = name;
|
|
390
|
+
validateProviderConfig(config);
|
|
391
|
+
return { changed: true, value: true };
|
|
392
|
+
});
|
|
393
|
+
if (!changed) {
|
|
406
394
|
if (wantsJson) {
|
|
407
395
|
console.log(JSON.stringify({ action: "noop", provider: name, defaultProvider: name, needsSync: false }, null, 2));
|
|
408
396
|
} else {
|
|
@@ -411,10 +399,6 @@ function handleSetDefault(args: string[]): void {
|
|
|
411
399
|
return;
|
|
412
400
|
}
|
|
413
401
|
|
|
414
|
-
config.defaultProvider = name;
|
|
415
|
-
validateAndSave(config);
|
|
416
|
-
|
|
417
|
-
|
|
418
402
|
if (wantsJson) {
|
|
419
403
|
console.log(JSON.stringify({ action: "set-default", provider: name, defaultProvider: name, needsSync: true }, null, 2));
|
|
420
404
|
return;
|
package/src/codex/convergence.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
|
|
3
|
-
import { getConfigDir,
|
|
4
|
-
import {
|
|
3
|
+
import { getConfigDir, mutatePersistedConfig, websocketsEnabled, withExpectedConfigGenerationSync } from "../config";
|
|
4
|
+
import {
|
|
5
|
+
reconcileSuccessfulModelDiscoveries,
|
|
6
|
+
type KnownModelBaseline,
|
|
7
|
+
} from "../providers/new-model-policy";
|
|
5
8
|
import { COMBO_NAMESPACE } from "../combos";
|
|
6
9
|
import { getAuthStorePath } from "../oauth/store";
|
|
7
|
-
import type { OcxConfig } from "../types";
|
|
10
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
8
11
|
import { captureCatalogAdmissionSnapshot } from "./catalog-admission";
|
|
9
12
|
import { legacyCustomModelCatalogSlugs } from "./custom-model-catalog-migration";
|
|
10
13
|
import {
|
|
@@ -137,7 +140,22 @@ interface CandidateState {
|
|
|
137
140
|
readonly changed: boolean;
|
|
138
141
|
readonly notices: readonly CatalogNotice[];
|
|
139
142
|
readonly modelEntitlements: CodexModelEntitlementSnapshot;
|
|
140
|
-
readonly
|
|
143
|
+
readonly discovery: DiscoveryEvidence;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface DiscoveryEvidence {
|
|
147
|
+
readonly models: readonly { provider: string; id: string; custom?: boolean }[];
|
|
148
|
+
readonly authoritativeProviders: readonly string[];
|
|
149
|
+
readonly providerIdentities: Readonly<Record<string, string>>;
|
|
150
|
+
readonly now: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface DiscoveryProjection {
|
|
154
|
+
readonly providers: Readonly<Record<string, Readonly<{
|
|
155
|
+
knownModel?: KnownModelBaseline;
|
|
156
|
+
recentArrivals?: readonly { id: string; at: string }[];
|
|
157
|
+
}>>>;
|
|
158
|
+
readonly disabledModelsToAdd: readonly string[];
|
|
141
159
|
}
|
|
142
160
|
|
|
143
161
|
const candidateStates = new WeakMap<object, CandidateState>();
|
|
@@ -145,6 +163,16 @@ function same(left: unknown, right: unknown): boolean {
|
|
|
145
163
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
146
164
|
}
|
|
147
165
|
|
|
166
|
+
function discoveryProviderIdentity(provider: OcxProviderConfig | undefined): string {
|
|
167
|
+
if (!provider) return "missing";
|
|
168
|
+
const row: Record<string, unknown> = { ...provider };
|
|
169
|
+
delete row.note;
|
|
170
|
+
delete row.newModelPolicy;
|
|
171
|
+
return JSON.stringify(row, (_key, value) => value && typeof value === "object" && !Array.isArray(value)
|
|
172
|
+
? Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([left], [right]) => left.localeCompare(right)))
|
|
173
|
+
: value);
|
|
174
|
+
}
|
|
175
|
+
|
|
148
176
|
function targetPath(identity: string): string {
|
|
149
177
|
const parsed = JSON.parse(identity) as { path?: unknown };
|
|
150
178
|
if (typeof parsed.path !== "string") throw new TypeError("Catalog target identity has no path.");
|
|
@@ -441,13 +469,15 @@ export async function gatherCodexCatalogCandidate(
|
|
|
441
469
|
: !hasRoutedEntries(source.catalog) ? source.catalog : null)
|
|
442
470
|
: null);
|
|
443
471
|
const discoveryConfig = structuredClone(snapshot.config) as OcxConfig;
|
|
444
|
-
const
|
|
472
|
+
const authoritativeProviders = providerModelOutcomes
|
|
473
|
+
.filter(outcome => outcome.state === "authoritative")
|
|
474
|
+
.map(outcome => outcome.provider);
|
|
475
|
+
const discoveryNow = new Date().toISOString();
|
|
476
|
+
reconcileSuccessfulModelDiscoveries({
|
|
445
477
|
config: discoveryConfig,
|
|
446
478
|
models: routedModels,
|
|
447
|
-
authoritativeProviders
|
|
448
|
-
|
|
449
|
-
.map(outcome => outcome.provider),
|
|
450
|
-
now: new Date().toISOString(),
|
|
479
|
+
authoritativeProviders,
|
|
480
|
+
now: discoveryNow,
|
|
451
481
|
});
|
|
452
482
|
const preparedCatalog = prepareCatalog(
|
|
453
483
|
discoveryConfig,
|
|
@@ -515,7 +545,15 @@ export async function gatherCodexCatalogCandidate(
|
|
|
515
545
|
|| Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes,
|
|
516
546
|
notices: Object.freeze([...notices]),
|
|
517
547
|
modelEntitlements,
|
|
518
|
-
|
|
548
|
+
discovery: {
|
|
549
|
+
models: routedModels,
|
|
550
|
+
authoritativeProviders,
|
|
551
|
+
providerIdentities: Object.fromEntries(authoritativeProviders.map(provider => [
|
|
552
|
+
provider,
|
|
553
|
+
discoveryProviderIdentity(snapshot.config.providers[provider]),
|
|
554
|
+
])),
|
|
555
|
+
now: discoveryNow,
|
|
556
|
+
},
|
|
519
557
|
});
|
|
520
558
|
return { kind: "candidate", candidate };
|
|
521
559
|
} catch (error) {
|
|
@@ -532,6 +570,63 @@ export async function gatherCodexCatalogCandidate(
|
|
|
532
570
|
}
|
|
533
571
|
}
|
|
534
572
|
|
|
573
|
+
function reconcilePersistedDiscovery(state: CandidateState) {
|
|
574
|
+
return mutatePersistedConfig(config => {
|
|
575
|
+
if (state.discovery.authoritativeProviders.some(provider => (
|
|
576
|
+
discoveryProviderIdentity(config.providers[provider]) !== state.discovery.providerIdentities[provider]
|
|
577
|
+
))) {
|
|
578
|
+
return { changed: false, value: null };
|
|
579
|
+
}
|
|
580
|
+
const disabledBefore = new Set(config.disabledModels ?? []);
|
|
581
|
+
const changed = reconcileSuccessfulModelDiscoveries({
|
|
582
|
+
config,
|
|
583
|
+
...state.discovery,
|
|
584
|
+
});
|
|
585
|
+
const providers = Object.fromEntries(state.discovery.authoritativeProviders.flatMap(provider => {
|
|
586
|
+
const configured = config.providers[provider];
|
|
587
|
+
if (!configured || configured.liveModels === false) return [];
|
|
588
|
+
const knownModel = config.modelDiscovery?.knownModels?.[provider];
|
|
589
|
+
const recentArrivals = config.modelDiscovery?.recentArrivals?.[provider];
|
|
590
|
+
return [[provider, {
|
|
591
|
+
...(knownModel ? { knownModel: structuredClone(knownModel) } : {}),
|
|
592
|
+
...(recentArrivals ? { recentArrivals: structuredClone(recentArrivals) } : {}),
|
|
593
|
+
}]];
|
|
594
|
+
}));
|
|
595
|
+
return {
|
|
596
|
+
changed,
|
|
597
|
+
value: {
|
|
598
|
+
providers,
|
|
599
|
+
disabledModelsToAdd: (config.disabledModels ?? []).filter(slug => !disabledBefore.has(slug)),
|
|
600
|
+
} satisfies DiscoveryProjection,
|
|
601
|
+
};
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function adoptDiscoveryProjection(config: OcxConfig, projection: DiscoveryProjection): void {
|
|
606
|
+
for (const [provider, discovered] of Object.entries(projection.providers)) {
|
|
607
|
+
if (discovered.knownModel) {
|
|
608
|
+
const discovery = config.modelDiscovery ??= {};
|
|
609
|
+
const knownModels = discovery.knownModels ??= {};
|
|
610
|
+
knownModels[provider] = structuredClone(discovered.knownModel);
|
|
611
|
+
} else if (config.modelDiscovery?.knownModels) {
|
|
612
|
+
delete config.modelDiscovery.knownModels[provider];
|
|
613
|
+
}
|
|
614
|
+
if (discovered.recentArrivals) {
|
|
615
|
+
const discovery = config.modelDiscovery ??= {};
|
|
616
|
+
const recentArrivals = discovery.recentArrivals ??= {};
|
|
617
|
+
recentArrivals[provider] = discovered.recentArrivals.map(arrival => ({ ...arrival }));
|
|
618
|
+
} else if (config.modelDiscovery?.recentArrivals) {
|
|
619
|
+
delete config.modelDiscovery.recentArrivals[provider];
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (projection.disabledModelsToAdd.length > 0) {
|
|
623
|
+
const disabledModels = config.disabledModels ??= [];
|
|
624
|
+
for (const slug of projection.disabledModelsToAdd) {
|
|
625
|
+
if (!disabledModels.includes(slug)) disabledModels.push(slug);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
535
630
|
function revalidateCandidate(state: CandidateState): CodexCatalogCommitResult | null {
|
|
536
631
|
if (!isCodexModelEntitlementSnapshotCurrent(state.modelEntitlements)) {
|
|
537
632
|
return { kind: "stale", reason: "account-entitlement" };
|
|
@@ -663,11 +758,11 @@ export async function convergeCodexCatalog(
|
|
|
663
758
|
const state = candidateStates.get(gathered.candidate as object)!;
|
|
664
759
|
lifecycle.onCommitBegin?.();
|
|
665
760
|
const committed = await commitCodexCatalogCandidate(gathered.candidate, request.deadlineMs);
|
|
666
|
-
if (committed.kind === "committed"
|
|
667
|
-
const
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
761
|
+
if (committed.kind === "committed") {
|
|
762
|
+
const persisted = reconcilePersistedDiscovery(state);
|
|
763
|
+
if ((persisted.status === "committed" || persisted.status === "unchanged") && persisted.value) {
|
|
764
|
+
adoptDiscoveryProjection(snapshot.config as OcxConfig, persisted.value);
|
|
765
|
+
}
|
|
671
766
|
}
|
|
672
767
|
return {
|
|
673
768
|
changed: committed.kind === "committed" ? committed.changed : false,
|
package/src/config.ts
CHANGED
|
@@ -96,7 +96,6 @@ import {
|
|
|
96
96
|
isValidCost4Rate,
|
|
97
97
|
refreshPreservedProviderOwner,
|
|
98
98
|
refreshUserCostOverlays,
|
|
99
|
-
withPreservedDiskOnlyProviders,
|
|
100
99
|
} from "./usage/user-cost-overlays";
|
|
101
100
|
import { MAX_COST4_RATE } from "./usage/expected-prices";
|
|
102
101
|
import {
|
|
@@ -106,6 +105,8 @@ import {
|
|
|
106
105
|
} from "./lib/app-owned-memory";
|
|
107
106
|
import { isHostedToolUnsupportedForModel } from "./responses/hosted-tool-policy";
|
|
108
107
|
import {
|
|
108
|
+
AtomicWriteResidualTempError,
|
|
109
|
+
AtomicWriteSecretResidualError,
|
|
109
110
|
atomicWriteFile,
|
|
110
111
|
isMissingPathError,
|
|
111
112
|
nextAtomicTempSequence,
|
|
@@ -2732,23 +2733,34 @@ export const withExpectedConfigGenerationSync: WithExpectedConfigGenerationSync
|
|
|
2732
2733
|
* cost-overlay registry from the persisted config so runtime estimates follow
|
|
2733
2734
|
* every save path.
|
|
2734
2735
|
*/
|
|
2735
|
-
|
|
2736
|
+
type PersistConfigAuthority = "ordinary" | "mutation" | "replacement";
|
|
2737
|
+
|
|
2738
|
+
function persistConfigUnlocked(config: OcxConfig, authority: PersistConfigAuthority = "ordinary"): boolean {
|
|
2736
2739
|
const configPath = getConfigPath();
|
|
2737
2740
|
// Check the resolved file target before reading it: a symlink can point from an
|
|
2738
2741
|
// isolated test home into the protected real home, where another write guard
|
|
2739
2742
|
// must not mask this refusal based on the target's current contents.
|
|
2740
2743
|
assertNotRealHomeUnderTest(dirname(resolveWriteTarget(configPath)));
|
|
2741
2744
|
const raw = readRawConfigJson();
|
|
2742
|
-
if (raw && configNeedsProviderRepair(raw)) {
|
|
2745
|
+
if (authority !== "replacement" && raw && configNeedsProviderRepair(raw)) {
|
|
2743
2746
|
throw new Error("refusing to overwrite a config repaired with defaults; fix the persisted config first");
|
|
2744
2747
|
}
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
//
|
|
2748
|
+
const snapshot = readConfigFileSnapshot();
|
|
2749
|
+
if (authority !== "replacement" && snapshot.diagnostics.source === "fallback") {
|
|
2750
|
+
throw new Error("refusing to overwrite an invalid persisted config; fix the persisted config first");
|
|
2751
|
+
}
|
|
2752
|
+
// Automatic whole-config writes own non-provider settings only. A valid disk
|
|
2753
|
+
// registry is authoritative; explicit locked mutations pass replacement
|
|
2754
|
+
// authority for intentional provider/default changes.
|
|
2755
|
+
const base = snapshot.diagnostics.source === "file" && authority === "ordinary"
|
|
2756
|
+
? {
|
|
2757
|
+
...config,
|
|
2758
|
+
providers: snapshot.diagnostics.config.providers,
|
|
2759
|
+
defaultProvider: snapshot.diagnostics.config.defaultProvider,
|
|
2760
|
+
}
|
|
2761
|
+
: config;
|
|
2750
2762
|
const provenanceProjection = projectConfigRebaseProvenance(config);
|
|
2751
|
-
const persisted =
|
|
2763
|
+
const persisted = base;
|
|
2752
2764
|
if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance;
|
|
2753
2765
|
else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
|
|
2754
2766
|
const bytes = JSON.stringify(persisted, null, 2) + "\n";
|
|
@@ -2790,6 +2802,183 @@ export function saveConfig(config: OcxConfig): void {
|
|
|
2790
2802
|
});
|
|
2791
2803
|
}
|
|
2792
2804
|
|
|
2805
|
+
/** Replace a validated config under the shared lock for confirmed import/init flows. */
|
|
2806
|
+
export function replacePersistedConfig(config: OcxConfig): void {
|
|
2807
|
+
assertNotRealHomeUnderTest(getConfigDir());
|
|
2808
|
+
withConfigMutationLockSync(() => {
|
|
2809
|
+
const projected = projectCustomModelCatalogMigration(
|
|
2810
|
+
readRawConfigJson(),
|
|
2811
|
+
projectConfigRebaseProvenance(config),
|
|
2812
|
+
);
|
|
2813
|
+
if (persistConfigUnlocked(projected, "replacement")) bumpGenerationForCooperatingConfigWrite();
|
|
2814
|
+
adoptCustomModelCatalogMigration(config, projected);
|
|
2815
|
+
if (projected.configRebaseProvenance === undefined) delete config.configRebaseProvenance;
|
|
2816
|
+
else config.configRebaseProvenance = structuredClone(projected.configRebaseProvenance);
|
|
2817
|
+
clearPendingConfigTopLevelDeletions(config);
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
export type PersistedConfigInitializationOutcome = "created" | "exists" | "invalid";
|
|
2822
|
+
|
|
2823
|
+
export class PersistedConfigInitializationCleanupError extends Error {
|
|
2824
|
+
constructor(options?: ErrorOptions) {
|
|
2825
|
+
super("Initial config publication cleanup failed after rollback", options);
|
|
2826
|
+
this.name = "PersistedConfigInitializationCleanupError";
|
|
2827
|
+
}
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
export class PersistedConfigInitializationRollbackError extends Error {
|
|
2831
|
+
constructor(options?: ErrorOptions) {
|
|
2832
|
+
super("Initial config publication rollback failed", options);
|
|
2833
|
+
this.name = "PersistedConfigInitializationRollbackError";
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
export interface PersistedConfigInitializationIO {
|
|
2838
|
+
createExclusive(path: string): void;
|
|
2839
|
+
write(path: string, bytes: string): void;
|
|
2840
|
+
harden(path: string): void;
|
|
2841
|
+
publishNoReplace(temp: string, target: string): void;
|
|
2842
|
+
truncate(path: string): void;
|
|
2843
|
+
unlink(path: string): void;
|
|
2844
|
+
}
|
|
2845
|
+
|
|
2846
|
+
let persistedConfigInitializationBeforePublishForTests: (() => void) | null = null;
|
|
2847
|
+
|
|
2848
|
+
/** Test-only one-shot seam: create a competing config after staging, before no-replace publication. */
|
|
2849
|
+
export function setPersistedConfigInitializationBeforePublishForTests(hook: (() => void) | null): void {
|
|
2850
|
+
persistedConfigInitializationBeforePublishForTests = hook;
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
function publishInitialConfigNoReplace(
|
|
2854
|
+
config: OcxConfig,
|
|
2855
|
+
io: PersistedConfigInitializationIO,
|
|
2856
|
+
): boolean {
|
|
2857
|
+
const configPath = getConfigPath();
|
|
2858
|
+
const target = resolveWriteTarget(configPath);
|
|
2859
|
+
assertNotRealHomeUnderTest(dirname(target));
|
|
2860
|
+
recordOwnedConfigPath(getConfigDir(), configPath);
|
|
2861
|
+
const persisted = projectConfigRebaseProvenance(config);
|
|
2862
|
+
const bytes = JSON.stringify(persisted, null, 2) + "\n";
|
|
2863
|
+
const temp = `${target}.ocx.${process.pid}.${nextAtomicTempSequence()}.tmp`;
|
|
2864
|
+
let staged = false;
|
|
2865
|
+
let hardened = false;
|
|
2866
|
+
let published = false;
|
|
2867
|
+
let cleanupAttempted = false;
|
|
2868
|
+
|
|
2869
|
+
const scrubUnpublishedTemp = (cause?: unknown): void => {
|
|
2870
|
+
cleanupAttempted = true;
|
|
2871
|
+
let scrubbed = false;
|
|
2872
|
+
try {
|
|
2873
|
+
io.truncate(temp);
|
|
2874
|
+
scrubbed = true;
|
|
2875
|
+
} catch (error) {
|
|
2876
|
+
if (isMissingPathError(error)) scrubbed = true;
|
|
2877
|
+
else {
|
|
2878
|
+
try { io.write(temp, ""); scrubbed = true; } catch { /* removal may still succeed */ }
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2881
|
+
let removed = false;
|
|
2882
|
+
try {
|
|
2883
|
+
io.unlink(temp);
|
|
2884
|
+
removed = true;
|
|
2885
|
+
} catch (error) {
|
|
2886
|
+
if (isMissingPathError(error)) removed = true;
|
|
2887
|
+
else {
|
|
2888
|
+
try { io.unlink(temp); removed = true; }
|
|
2889
|
+
catch (retryError) { if (isMissingPathError(retryError)) removed = true; }
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
if (removed) forgetEphemeralSecretPath(temp);
|
|
2893
|
+
if (!removed && !scrubbed) throw new AtomicWriteSecretResidualError(temp, { cause });
|
|
2894
|
+
if (!removed) throw new AtomicWriteResidualTempError(temp, hardened, { cause });
|
|
2895
|
+
};
|
|
2896
|
+
|
|
2897
|
+
try {
|
|
2898
|
+
io.createExclusive(temp);
|
|
2899
|
+
staged = true;
|
|
2900
|
+
io.write(temp, bytes);
|
|
2901
|
+
io.harden(temp);
|
|
2902
|
+
hardened = true;
|
|
2903
|
+
const hook = persistedConfigInitializationBeforePublishForTests;
|
|
2904
|
+
persistedConfigInitializationBeforePublishForTests = null;
|
|
2905
|
+
hook?.();
|
|
2906
|
+
try {
|
|
2907
|
+
io.publishNoReplace(temp, target);
|
|
2908
|
+
} catch (cause) {
|
|
2909
|
+
if (!isAlreadyExistsError(cause)) throw cause;
|
|
2910
|
+
scrubUnpublishedTemp(cause);
|
|
2911
|
+
return false;
|
|
2912
|
+
}
|
|
2913
|
+
published = true;
|
|
2914
|
+
try {
|
|
2915
|
+
io.unlink(temp);
|
|
2916
|
+
forgetEphemeralSecretPath(temp);
|
|
2917
|
+
} catch (firstError) {
|
|
2918
|
+
if (isMissingPathError(firstError)) {
|
|
2919
|
+
forgetEphemeralSecretPath(temp);
|
|
2920
|
+
} else try {
|
|
2921
|
+
io.unlink(temp);
|
|
2922
|
+
forgetEphemeralSecretPath(temp);
|
|
2923
|
+
} catch (secondError) {
|
|
2924
|
+
if (isMissingPathError(secondError)) {
|
|
2925
|
+
forgetEphemeralSecretPath(temp);
|
|
2926
|
+
} else {
|
|
2927
|
+
// Both names point to one inode. Remove the published name before scrubbing.
|
|
2928
|
+
try { io.unlink(target); }
|
|
2929
|
+
catch (cause) { throw new PersistedConfigInitializationRollbackError({ cause }); }
|
|
2930
|
+
published = false;
|
|
2931
|
+
scrubUnpublishedTemp(secondError);
|
|
2932
|
+
throw new PersistedConfigInitializationCleanupError({ cause: secondError });
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
refreshUserCostOverlays(persisted);
|
|
2937
|
+
return true;
|
|
2938
|
+
} catch (cause) {
|
|
2939
|
+
if (staged && !published && !cleanupAttempted) scrubUnpublishedTemp(cause);
|
|
2940
|
+
throw cause;
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
function defaultPersistedConfigInitializationIO(configPath: string): PersistedConfigInitializationIO {
|
|
2945
|
+
return {
|
|
2946
|
+
createExclusive: target => { writeFileSync(target, "", { flag: "wx", mode: 0o600 }); },
|
|
2947
|
+
write: (target, bytes) => writeFileSync(target, bytes),
|
|
2948
|
+
harden: target => {
|
|
2949
|
+
try { chmodSync(target, 0o600); } catch { /* platform may ignore chmod */ }
|
|
2950
|
+
if (process.platform === "win32") hardenSecretPath(target, { required: true, timeoutMemoKey: configPath });
|
|
2951
|
+
},
|
|
2952
|
+
publishNoReplace: (temp, target) => linkSync(temp, target),
|
|
2953
|
+
truncate: target => truncateSync(target, 0),
|
|
2954
|
+
unlink: unlinkSync,
|
|
2955
|
+
};
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
/** Create the initial config under the shared lock, but never replace existing bytes. */
|
|
2959
|
+
export function initializePersistedConfigIfMissing(
|
|
2960
|
+
config: OcxConfig,
|
|
2961
|
+
io = defaultPersistedConfigInitializationIO(getConfigPath()),
|
|
2962
|
+
): PersistedConfigInitializationOutcome {
|
|
2963
|
+
assertNotRealHomeUnderTest(getConfigDir());
|
|
2964
|
+
return withConfigMutationLockSync(() => {
|
|
2965
|
+
const snapshot = readConfigFileSnapshot();
|
|
2966
|
+
if (snapshot.diagnostics.source === "file") return "exists";
|
|
2967
|
+
if (snapshot.diagnostics.source !== "default") return "invalid";
|
|
2968
|
+
const projected = projectCustomModelCatalogMigration(
|
|
2969
|
+
readRawConfigJson(),
|
|
2970
|
+
projectConfigRebaseProvenance(config),
|
|
2971
|
+
);
|
|
2972
|
+
if (!publishInitialConfigNoReplace(projected, io)) {
|
|
2973
|
+
const winner = readConfigFileSnapshot();
|
|
2974
|
+
return winner.diagnostics.source === "file" ? "exists" : "invalid";
|
|
2975
|
+
}
|
|
2976
|
+
bumpGenerationForCooperatingConfigWrite();
|
|
2977
|
+
adoptCustomModelCatalogMigration(config, projected);
|
|
2978
|
+
return "created";
|
|
2979
|
+
});
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2793
2982
|
export type PersistedConfigMutation<T> = {
|
|
2794
2983
|
changed: boolean;
|
|
2795
2984
|
value: T;
|
|
@@ -2799,6 +2988,13 @@ export type PersistedConfigMutationOutcome<T> =
|
|
|
2799
2988
|
| { status: "committed" | "unchanged"; value: T }
|
|
2800
2989
|
| { status: "unavailable"; reason: "missing" | "invalid" | "conflict" };
|
|
2801
2990
|
|
|
2991
|
+
export class ConfigMutationValidationError extends Error {
|
|
2992
|
+
constructor(readonly validationError: string) {
|
|
2993
|
+
super(`Config mutation rejected: ${validationError}`);
|
|
2994
|
+
this.name = "ConfigMutationValidationError";
|
|
2995
|
+
}
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2802
2998
|
const CONFIG_MUTATION_MAX_REBASE_ATTEMPTS = 3;
|
|
2803
2999
|
let persistedConfigMutationBeforeCommitForTests: (() => void) | null = null;
|
|
2804
3000
|
|
|
@@ -2871,7 +3067,9 @@ export function mutatePersistedConfig<T>(
|
|
|
2871
3067
|
commitBase.diagnostics.config,
|
|
2872
3068
|
confirmedConfig,
|
|
2873
3069
|
);
|
|
2874
|
-
|
|
3070
|
+
const validation = validateConfigCandidate(projected);
|
|
3071
|
+
if (!validation.ok) throw new ConfigMutationValidationError(validation.error);
|
|
3072
|
+
if (persistConfigUnlocked(projected, "mutation")) bumpGenerationForCooperatingConfigWrite();
|
|
2875
3073
|
return { status: "committed", value: confirmed.value };
|
|
2876
3074
|
}
|
|
2877
3075
|
return { status: "unavailable", reason: "conflict" };
|
|
@@ -3193,7 +3391,7 @@ function readPersistedServerBinding(
|
|
|
3193
3391
|
* conflict keeps the live value;
|
|
3194
3392
|
* - a provider or custom-model row deleted on disk stays deleted even if stale
|
|
3195
3393
|
* live state edited that same row;
|
|
3196
|
-
* - file
|
|
3394
|
+
* - missing file → save what we have; invalid existing file → fail closed.
|
|
3197
3395
|
*
|
|
3198
3396
|
* Custom-model rows are merged by their stable `id`, preserving independent
|
|
3199
3397
|
* edits and deletions across stale whole-config saves.
|