Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs-site/src/content/docs/guides/model-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ Routing and catalog visibility are separate controls:
- Fresh installs set `modelDiscovery.newModelPolicy` to `"off"`. After the first successful live
fetch establishes a baseline, later arrivals are appended to `disabledModels` and carry a **NEW**
dashboard badge until enabled or acknowledged. Existing installs remain `"on"` until opted in.
The policy applies before publishing newly discovered models through `/v1/models`, the dashboard,
client configuration exports, or a Codex catalog sync (including service startup).
Enabling an arrival manually keeps it enabled on later refreshes and exports.
Comment thread
colthreepv marked this conversation as resolved.
Use `ocx models new-policy off` globally, add `--provider <name>` for an override, and inspect
`ocx models new-arrivals [--json]`. Failed/degraded fetches never change the baseline. Providers
with a non-empty `selectedModels` (including preset mode) are already curated, so this policy is
Expand Down
4 changes: 4 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,7 @@
"codex-spark-visibility.test.ts": "codex-integration",
"codex-sqlite-home.test.ts": "codex-integration",
"codex-sync-api.test.ts": "codex-integration",
"codex-sync-new-model-policy.test.ts": "codex-integration",
"codex-sync-response.test.ts": "codex-integration",
"codex-tool-mode.test.ts": "codex-integration",
"codex-transition-state-adoption.test.ts": "codex-integration",
Expand Down Expand Up @@ -1065,6 +1066,7 @@
"management-metrics-export.test.ts": "server",
"management-model-roster-gather-race.test.ts": "server",
"management-model-roster.test.ts": "server",
"model-export-new-model-policy.test.ts": "server",
"management-origin-tls.test.ts": "server",
"management-provider-compat-carry.test.ts": "server",
"management-provider-hide-raw-reasoning.test.ts": "server",
Expand Down Expand Up @@ -1097,6 +1099,7 @@
"model-cache.test.ts": "codex-integration",
"model-costs-management-api.test.ts": "server",
"model-discovery-management-api.test.ts": "server",
"server-new-model-policy-arrival.test.ts": "server",
"model-display-names-management-api.test.ts": "codex-integration",
"model-metadata-sync.test.ts": "codex-integration",
"model-pinned-effort-config.test.ts": "config",
Expand Down Expand Up @@ -1143,6 +1146,7 @@
"native-profile-startup.test.ts": "codex-integration",
"native-profile-store.test.ts": "codex-integration",
"new-model-policy.test.ts": "providers",
"new-model-policy-runtime.test.ts": "providers",
"nous-oauth-live.test.ts": "providers",
"nous-oauth.test.ts": "providers",
"novita-provider.test.ts": "providers",
Expand Down
9 changes: 9 additions & 0 deletions src/codex/catalog/retained-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration
import { getCodexHome } from "../paths";
import type { OcxConfig } from "../../types";
import { pendingModelSelectionProviders } from "../../providers/initial-model-selection";
import { captureModelDiscoveryBaseline, finalizeModelDiscovery } from "../../providers/new-model-policy-runtime";
import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
import { providerCodexAccountMode } from "../../providers/registry";
import { COMBO_NAMESPACE } from "../../combos";
Expand Down Expand Up @@ -602,10 +603,13 @@ export async function syncCatalogModels(
evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog),
processEvidence: retainedCatalogProcessEvidence(),
};
const discoveryBaseline = captureModelDiscoveryBaseline(config);
const providerContentRevisions = new Map<string, string>();
const [goModels, modelEntitlements] = await Promise.all([
gatherRoutedModels(config, {
comboOmissions,
providerModelOutcomes,
providerContentRevisions,
}),
resolveAdmittedCodexModelEntitlements(config),
]);
Expand All @@ -629,6 +633,11 @@ export async function syncCatalogModels(
const current = revalidateRetainedCatalogSync(config, prepared);
if (current === null) return null;
if (!isCodexModelEntitlementSnapshotCurrent(modelEntitlements)) return null;
// Revalidate before adopting discovery: its scoped config mutation would otherwise
// invalidate our own evidence. K -> C is the catalog/config lock order.
if (!finalizeModelDiscovery(config, discoveryBaseline, goModels,
providerModelOutcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider),
providerContentRevisions)) return null;
return writeRetainedCatalogSync({
config,
goModels,
Expand Down
18 changes: 18 additions & 0 deletions src/config/live-reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,24 @@ const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
* reconciliation paths below.
*/
const liveConfigBaseline = new WeakMap<OcxConfig, OcxConfig>();

/**
* Adopt a committed discovery decision and its merge baseline as one synchronous step.
* Visibility writers assign and save synchronously; any future writer yielding between
* those steps must preserve pending edits through a three-way merge here.
*/
export function adoptPersistedModelDiscovery(
config: OcxConfig,
persisted: Pick<OcxConfig, "modelDiscovery" | "disabledModels">,
): void {
config.modelDiscovery = structuredClone(persisted.modelDiscovery);
config.disabledModels = structuredClone(persisted.disabledModels);
const baseline = liveConfigBaseline.get(config);
if (baseline) {
baseline.modelDiscovery = structuredClone(persisted.modelDiscovery);
baseline.disabledModels = structuredClone(persisted.disabledModels);
}
}
/**
* The live config retains the address of the socket Bun actually opened, while
* this map retains the operator's desired address for the next process start.
Expand Down
66 changes: 66 additions & 0 deletions src/providers/new-model-policy-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { isDeepStrictEqual } from "node:util";
import { mutatePersistedConfig, readConfigDiagnostics, validateConfigCandidate } from "../config";
import { observeModelCacheRevision } from "../codex/model-cache";
import { adoptPersistedModelDiscovery } from "../config/live-reconcile";
import type { OcxConfig } from "../types";
import { reconcileSuccessfulModelDiscoveries } from "./new-model-policy";

function inventory(config: OcxConfig): unknown {
const validated = validateConfigCandidate(config);
if (!validated.ok) return null;
const providers = Object.fromEntries(Object.entries(validated.config.providers).map(([name, provider]) => [name, {
...provider,
// Initial selection may finish during this discovery, but a re-registration is distinct.
initialModelSelection: provider.initialModelSelection?.registrationId,
}]));
// Compare normalized persisted data, not injected fetch functions. Never log this identity.
return JSON.parse(JSON.stringify({ providers, combos: validated.config.combos,
policy: validated.config.modelDiscovery?.newModelPolicy }));
}

export function captureModelDiscoveryBaseline(config: OcxConfig) {
const identity = inventory(config);
const disk = readConfigDiagnostics();
return { identity, persisted: identity !== null && disk.source === "file"
&& isDeepStrictEqual(identity, inventory(disk.config)) };
}

/** Publish new-arrival choices before returning discovery rows to any visibility consumer. */
export function finalizeModelDiscovery(
config: OcxConfig,
baseline: ReturnType<typeof captureModelDiscoveryBaseline>,
models: Iterable<{ provider: string; id: string; custom?: boolean }>,
authoritativeProviders: string[],
revisions: Map<string, string>,
): boolean {
const rows = [...models];
const now = new Date().toISOString();
const current = () => isDeepStrictEqual(baseline.identity, inventory(config))
&& [...revisions].every(([provider, revision]) => observeModelCacheRevision(provider) === revision);
if (!current()) return false;
const reconcile = (target: OcxConfig) => reconcileSuccessfulModelDiscoveries({
config: target, models: rows, authoritativeProviders, now, mode: "discovery",
});
if (!baseline.persisted) {
// Standalone/synthetic callers may project their own config, never overwrite another home.
reconcile(config);
return true;
}
Comment thread
colthreepv marked this conversation as resolved.
try {
const result = mutatePersistedConfig(fresh => {
if (!current() || !isDeepStrictEqual(baseline.identity, inventory(fresh))) {
return { changed: false, value: null };
}
// Re-evaluate against the latest baseline and manual choices under the mutation lock.
// A second caller then adopts a first caller's result rather than disabling an ID twice.
const changed = reconcile(fresh);
return { changed, value: { modelDiscovery: fresh.modelDiscovery, disabledModels: fresh.disabledModels } };
});
if (result.status === "unavailable" || !result.value) return false;
adoptPersistedModelDiscovery(config, result.value);
return true;
} catch {
// Do not publish an unrecorded arrival when persistence failed. The caller retries discovery.
return false;
}
}
25 changes: 25 additions & 0 deletions src/providers/new-model-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ export const MAX_RECENT_ARRIVALS_PER_PROVIDER = 50;
export type KnownModelBaseline = NonNullable<NonNullable<OcxConfig["modelDiscovery"]>["knownModels"]>[string];
export type NewModelPolicy = "on" | "off";

/**
* Which side of the reconciliation is running.
*
* `converge` (the default) is the catalog convergence cycle: it advances the removal grace and
* retires ids missing for {@link MODEL_REMOVAL_GRACE_FETCHES} cycles. `discovery` is the HTTP read
* side (GET /v1/models), which is polled far more often than convergence runs; it absorbs genuine
* arrivals and reappearances but never advances removal accounting, so a transiently absent id
* cannot be retired by repeated polls.
*/
export type NewModelPolicyMode = "converge" | "discovery";

export interface NewModelPolicyResult {
newIds: string[];
nextBaseline: KnownModelBaseline;
Expand Down Expand Up @@ -44,6 +55,8 @@ export function applyNewModelPolicy(options: {
policy: NewModelPolicy;
hasSelectedModels?: boolean;
now: string;
/** See {@link NewModelPolicyMode}; defaults to `converge`. */
mode?: NewModelPolicyMode;
}): NewModelPolicyResult {
const discovered = [...new Set(options.discoveredIds)].sort();
const prior = options.baseline;
Expand All @@ -60,8 +73,17 @@ export function applyNewModelPolicy(options: {
const seen = new Set(discovered);
const newIds = discovered.filter(id => !active.has(id) && !removed.has(id));
const missing: Record<string, number> = {};
// Read-side discovery must not advance the removal grace: a per-poll increment would retire a
// transiently absent id after three polls. Absence keeps its recorded count (and a reappearance
// clears it) instead of moving toward `removed`.
const discovery = options.mode === "discovery";
for (const id of active) {
if (seen.has(id)) continue;
if (discovery) {
const priorCount = prior.missing?.[id];
if (priorCount !== undefined) missing[id] = priorCount;
continue;
}
const count = (prior.missing?.[id] ?? 0) + 1;
if (count >= MODEL_REMOVAL_GRACE_FETCHES) {
active.delete(id);
Expand Down Expand Up @@ -99,6 +121,8 @@ export function reconcileSuccessfulModelDiscoveries(options: {
models: Iterable<{ provider: string; id: string; custom?: boolean }>;
authoritativeProviders: Iterable<string>;
now: string;
/** See {@link NewModelPolicyMode}; defaults to `converge`. */
mode?: NewModelPolicyMode;
}): boolean {
const byProvider = new Map<string, string[]>();
for (const model of options.models) {
Expand All @@ -118,6 +142,7 @@ export function reconcileSuccessfulModelDiscoveries(options: {
policy: effectiveNewModelPolicy(options.config, provider),
hasSelectedModels: (configured.selectedModels?.length ?? 0) > 0,
now: options.now,
mode: options.mode,
});
if (result.overflow) continue;
const priorBaseline = known[provider];
Expand Down
36 changes: 33 additions & 3 deletions src/server/management/model-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { CatalogModel } from "../../codex/catalog";
import { observeModelCacheRevision } from "../../codex/model-cache";
import {
captureExportConfigAdmission,
detachedConfigSnapshot,
isExportConfigAdmissionCurrent,
type ExportConfigAdmission,
} from "../../config/admitted-identity";
Expand All @@ -37,6 +38,9 @@ import type { OcxConfig } from "../../types";
import { enrichProviderFromRegistry } from "../../providers/derive";
import { ensureCodexEntitlementFreshness } from "../../codex/model-entitlements";
import { fetchAllModels } from "./shared";
import { CatalogGatherBusyError } from "../../codex/catalog/routed-gather";
import { captureModelDiscoveryBaseline, finalizeModelDiscovery } from "../../providers/new-model-policy-runtime";
import { reconcileSuccessfulModelDiscoveries } from "../../providers/new-model-policy";
import { initialModelSelectionPending, pendingModelSelectionProviders } from "../../providers/initial-model-selection";
import { catalogFastRowEligible, fastRowId } from "../fast-row";
import { knownEffortRowIds } from "../effort-row";
Expand Down Expand Up @@ -471,8 +475,10 @@ export async function loadExportModels(
// The configuration this pass will use from beginning to end, proved to be the one on disk.
// Without it there is nothing that may be retained, and the caller still gets its rows: only the
// preview authority is withheld.
const admission = captureExportConfigAdmission(config);
const admitted = admission?.config ?? config;
let admission = captureExportConfigAdmission(config);
let admitted = admission?.config ?? config;
const discoveryBaseline = models === undefined ? captureModelDiscoveryBaseline(admitted) : null;
const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = [];
// The gather stamps each provider as it chooses its rows, so the roster and the revisions that
// vouch for it come from the same moment. Sampling afterwards would let a concurrent flight's
// publication be recorded against rows it never produced.
Expand All @@ -483,10 +489,34 @@ export async function loadExportModels(
// had, and runs alongside as it did inside the projection.
const roster = models === undefined
? (await Promise.all([
(await import("../../codex/catalog")).gatherRoutedModels(admitted, { providerContentRevisions: gathered }),
(await import("../../codex/catalog")).gatherRoutedModels(admitted, {
providerContentRevisions: gathered, providerModelOutcomes: outcomes,
}),
ensureCodexEntitlementFreshness(admitted, { waitMs: 3_000 }),
]))[0]
: models;
if (discoveryBaseline !== null) {
const authoritative = outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider);
if (admission !== null && isExportConfigAdmissionCurrent(admission, config)
&& [...gathered].every(([provider, revision]) => observeModelCacheRevision(provider) === revision)) {
if (!finalizeModelDiscovery(config, discoveryBaseline, roster, authoritative, gathered)) {
lastExportSnapshot = null;
throw new CatalogGatherBusyError();
}
// Only our scoped discovery decision moved: bind the projection to the committed state.
admission = captureExportConfigAdmission(config);
admitted = admission?.config ?? config;
} else {
// Preserve the old-response contract after a race, without persisting stale evidence or
// retaining it as a current preview. Apply visibility policy only to a detached projection.
const projection = detachedConfigSnapshot(admitted);
if (projection === null) throw new CatalogGatherBusyError();
reconcileSuccessfulModelDiscoveries({ config: projection, models: roster,
authoritativeProviders: authoritative, now: new Date().toISOString(), mode: "discovery" });
admitted = projection;
admission = null;
}
}
const rows = await listManagementModelRows(admitted, { models: roster });
// Management deliberately lists the full roster so hidden models can be enabled.
// A client picker must also honor the provider selection, not just its blocklist.
Expand Down
11 changes: 9 additions & 2 deletions src/server/management/shared.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { randomUUID } from "node:crypto";
import { captureInitialSelectionBaseline, finalizeInitialModelSelection } from "../../providers/initial-model-selection-runtime";
import { captureModelDiscoveryBaseline, finalizeModelDiscovery } from "../../providers/new-model-policy-runtime";
import { CatalogGatherBusyError } from "../../codex/catalog/routed-gather";
import { initialModelSelectionPending } from "../../providers/initial-model-selection";
import { readFileSync } from "node:fs";
import type { CatalogModel } from "../../codex/catalog";
Expand Down Expand Up @@ -269,14 +271,19 @@ export async function fetchAllModels(
): Promise<CatalogModel[]> {
const { gatherRoutedModels } = await import("../../codex/catalog");
const baseline = captureInitialSelectionBaseline(config);
if (!baseline) return gatherRoutedModels(config, providerContentRevisions ? { providerContentRevisions } : undefined);
const discoveryBaseline = captureModelDiscoveryBaseline(config);
const revisions = providerContentRevisions ?? new Map<string, string>();
const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = [];
const models = await gatherRoutedModels(config, {
providerModelOutcomes: outcomes,
...(providerContentRevisions ? { providerContentRevisions } : {}),
providerContentRevisions: revisions,
});
finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models),
outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider));
if (!finalizeModelDiscovery(config, discoveryBaseline, models,
outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider), revisions)) {
throw new CatalogGatherBusyError();
}
return models;
}

Expand Down
2 changes: 1 addition & 1 deletion structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ this rule, and uninstall keeps the [manifest validation and residual reporting c

Cache invalidation reports an unchanged derived cache separately from a failed rewrite. `ocx sync-cache` treats identical bytes as a successful no-op, preserving the cache mtime and avoiding a needless app-server restart; malformed catalogs and write failures remain errors.

`src/codex/catalog/model-visibility.ts` also excludes models owned by disabled providers, including custom rows. `src/codex/catalog/routed-gather.ts` does not inherit provider configuration into custom rows while that provider is disabled.
`src/codex/catalog/model-visibility.ts` excludes disabled providers, including custom rows; `src/codex/catalog/routed-gather.ts` does not inherit their configuration into custom rows. HTTP discovery, startup/explicit sync (`src/codex/catalog/retained-sync.ts`), and client exports (`src/server/management/model-rows.ts`) apply new-model policy before publication. `src/providers/new-model-policy-runtime.ts` commits authoritative arrivals and automatic disables together under the config mutation lock, without rewriting an unchanged roster. It rechecks provider inventory and cache revisions and refuses publication on failed persistence. Retained sync reconciles after catalog evidence revalidation, preserving K-to-C lock order. Exports recapture their snapshot after committing discovery; superseded gathers only project policy onto a detached copy and retain no preview. Supplied rosters and read-only previews do not reconcile. Synthetic configurations only project their own state. Discovery absorbs arrivals/reappearances without advancing removal grace; convergence retains removal accounting. Bootstrap, custom rows, explicit selections, and degraded discovery retain the pure policy rules. Coverage: `tests/server/server-new-model-policy-arrival.test.ts` (HTTP), `tests/codex-integration/codex-sync-new-model-policy.test.ts` (startup/sync), `tests/server/model-export-new-model-policy.test.ts` (exports/previews), and `tests/providers/new-model-policy-runtime.test.ts` (persistence/concurrency).

On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's
bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies
Expand Down
Loading
Loading