Skip to content
Merged
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.
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
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/ja/guides/model-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ model ID は変更しません。`openai-apikey/<model>` は API key transport
ブロックしません。
- プロバイダーの `selectedModels` が空でなければカタログ許可リストとして動作します。ライブモデル探索と
直接ルーティングはそのままに、カタログと `/v1/models` に公開するモデルだけ絞ります。
- `modelDiscovery.newModelPolicy` が `"off"` の場合、最初の成功したライブ探索で基準を確立し、
その後に追加されたモデルを `disabledModels` に入れます。これは `/v1/models`、ダッシュボード、
クライアント設定のエクスポート、サービス起動時を含む Codex カタログ同期で公開する前に
適用されます。手動で有効化したモデルは、その後の更新やエクスポートでも有効なままです。
新規インストールの既定値は `"off"`、既存のインストールは変更するまで `"on"` です。
`ocx models new-policy off` で全体に適用し、`--provider <name>` で個別に上書きします。
`ocx models new-arrivals [--json]` で追加モデルを確認できます。失敗または縮退した探索では
基準は変わりません。`selectedModels` が空でないプロバイダーはすでに選別済みのため、
このポリシーによる自動無効化は行いません。
- `provider.disabled: true` のプロバイダーはカタログ探索から除外されます。明示的 `provider/model` リクエストは
失敗し、`defaultModel` / `models[]` 検査でもスキップします。
- `providerContextCaps` はプロバイダーごとに Codex に表示するコンテキスト上限を指定します。
Expand Down
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/ko/guides/model-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ fallback하지 않습니다.
막지는 않습니다.
- 프로바이더의 `selectedModels`가 비어 있지 않으면 카탈로그 허용 목록으로 동작합니다. 실시간 모델 탐색과
직접 라우팅은 그대로 두고, 카탈로그와 `/v1/models`에 내보낼 모델만 줄입니다.
- `modelDiscovery.newModelPolicy`가 `"off"`이면 첫 실시간 탐색 성공 시 기준을 만들고,
이후 추가된 모델을 `disabledModels`에 넣습니다. 이 정책은 `/v1/models`, 대시보드,
클라이언트 설정 내보내기, 서비스 시작을 포함한 Codex 카탈로그 동기화에서 새 모델을 공개하기
전에 적용됩니다. 수동으로 활성화한 모델은 이후 새로고침과 내보내기에서도 활성 상태를 유지합니다.
새 설치의 기본값은 `"off"`이며, 기존 설치는 직접 바꾸기 전까지 `"on"`을 유지합니다.
`ocx models new-policy off`로 전역 정책을 설정하고, `--provider <name>`으로 프로바이더별
정책을 지정합니다. `ocx models new-arrivals [--json]`으로 새 모델을 확인할 수 있습니다.
실패하거나 제한된 탐색은 기준을 바꾸지 않습니다. `selectedModels`가 비어 있지 않은
프로바이더는 이미 선택 목록을 사용하므로 이 정책으로 모델을 자동 비활성화하지 않습니다.
- `provider.disabled: true`인 프로바이더는 카탈로그 탐색에서 제외됩니다. 명시적 `provider/model` 요청은
실패하고, `defaultModel` / `models[]` 검사에서도 건너뜁니다.
- `providerContextCaps`는 공급자별로 Codex에 표시할 컨텍스트 상한을 지정합니다.
Expand Down
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/ru/guides/model-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ description: Как opencodex решает, какой провайдер буд
- Непустой `selectedModels` провайдера — ещё один список разрешённых для каталога. Живое
обнаружение и прямая маршрутизация продолжают работать; сужается только вывод в каталог и
`/v1/models`.
- При `modelDiscovery.newModelPolicy: "off"` первое успешное живое обнаружение задаёт базовый
список; последующие новые модели добавляются в `disabledModels` до публикации в `/v1/models`,
дашборде, экспорте конфигурации клиентов и синхронизации каталога Codex, включая запуск сервиса.
Модель, включённая вручную, остаётся включённой при последующих обновлениях и экспортах.
Новые установки используют `"off"`; существующие сохраняют `"on"`, пока пользователь не изменит
политику. `ocx models new-policy off` задаёт общую политику; `--provider <name>` переопределяет
её для провайдера. `ocx models new-arrivals [--json]` показывает новые модели. Неудачное или
деградировавшее обнаружение не меняет базовый список. Для провайдеров с непустым `selectedModels`
автоматическое отключение по этой политике не применяется: их список уже отобран.
- `provider.disabled: true` убирает провайдера из обнаружения каталога. Явные запросы
`provider/model` завершаются ошибкой, а проверки `defaultModel` / `models[]` его пропускают.
- `providerContextCaps` задаёт видимые для Codex лимиты контекста по провайдерам.
Expand Down
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/zh-cn/guides/model-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ transport;这些凭证路径互不 fallback。
仍保留在目录中,但会改为 `visibility: "hide"`。它**不会**拒绝对该模型的直接请求。
- 提供商的非空 `selectedModels` 是另一层目录 allowlist。实时发现和直接路由仍然有效;它只会缩小
目录和 `/v1/models` 输出的模型范围。
- 当 `modelDiscovery.newModelPolicy` 为 `"off"` 时,首次成功的实时发现建立基准,后续新增
模型会加入 `disabledModels`。此策略会在通过 `/v1/models`、仪表盘、客户端配置导出或
Codex 目录同步(包括服务启动)发布新模型之前生效。手动启用的模型在后续刷新和导出中仍保持
启用。新安装默认使用 `"off"`,已有安装在主动更改前保持 `"on"`。使用
`ocx models new-policy off` 设置全局策略,添加 `--provider <name>` 覆盖单个提供商的设置;
使用 `ocx models new-arrivals [--json]` 查看新增模型。失败或降级的发现不会改变基准。
非空 `selectedModels` 已经限定了提供商的模型列表,因此此策略不会再自动禁用模型。
- `provider.disabled: true` 会把该提供商排除在目录发现之外。显式 `provider/model` 请求会失败,
`defaultModel` / `models[]` 扫描也会跳过它。
- `providerContextCaps` 为各提供商设置 Codex 可见的上下文上限。`contextCapValue` 是仪表盘的默认值,
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 @@ -598,6 +598,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 @@ -1080,6 +1081,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 @@ -1112,6 +1114,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 @@ -1159,6 +1162,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
7 changes: 4 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { refreshConfigDerivedRegistries } from "./config/derived-registries";
import {
clearPendingConfigDeletions,
recordFileBackedConfig,
projectConfigRebaseProvenance,
} from "./config/rebase-provenance";
import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
Expand Down Expand Up @@ -249,7 +250,7 @@ export function loadConfig(): OcxConfig {
warnDegradedCatalogAutoRefresh(parsed);
warnDegradedCodexPool(parsed);
warnDegradedCredentialGroups(parsed);
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
return recordFileBackedConfig(withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)), configPath);
}
// Only object-shaped configs are repairable. Spreading another JSON value
// into defaults can manufacture a valid config and bypass the invalid-file
Expand Down Expand Up @@ -285,7 +286,7 @@ export function loadConfig(): OcxConfig {
warnDegradedCatalogAutoRefresh(parsed);
warnDegradedCodexPool(parsed);
warnDegradedCredentialGroups(parsed);
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
return recordFileBackedConfig(withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)), configPath);
}
// Still failing, but if every complaint is about one or more named entries
// in an independent section, drop exactly those and keep the rest. Falling
Expand Down Expand Up @@ -315,7 +316,7 @@ export function loadConfig(): OcxConfig {
warnDegradedCatalogAutoRefresh(parsed);
warnDegradedCodexPool(parsed);
warnDegradedCredentialGroups(parsed);
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
return recordFileBackedConfig(withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)), configPath);
}
}
// Merge couldn't fix it — truly broken config
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
12 changes: 12 additions & 0 deletions src/config/rebase-provenance.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import type { OcxConfig } from "../types";

// Object provenance survives disk drift without adding serializable config fields.
const fileBackedConfigs = new WeakMap<OcxConfig, string>();

export function recordFileBackedConfig(config: OcxConfig, path: string): OcxConfig {
fileBackedConfigs.set(config, path);
return config;
}

export function fileBackedConfigPath(config: OcxConfig): string | undefined {
return fileBackedConfigs.get(config);
}

const pendingTopLevelDeletions = new WeakMap<OcxConfig, Set<string>>();
const pendingObjectChildDeletions = new WeakMap<OcxConfig, Map<string, Set<string>>>();
export const CONFIG_REBASE_PROVENANCE_KEY = "configRebaseProvenance";
Expand Down
72 changes: 72 additions & 0 deletions src/providers/new-model-policy-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { isDeepStrictEqual } from "node:util";
import { mutatePersistedConfig, readConfigDiagnostics, validateConfigCandidate } from "../config";
import { fileBackedConfigPath } from "../config/rebase-provenance";
import { getConfigPath } from "../config/paths";
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 filePath = fileBackedConfigPath(config);
const rows = [...models];
const now = new Date().toISOString();
const current = () => (filePath === undefined || filePath === getConfigPath())
&& 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 && filePath === undefined) {
// Standalone/synthetic callers may project their own config, never overwrite another home.
reconcile(config);
Comment on lines +48 to +50

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve file provenance across startup clones

Treating every unmarked object as synthetic reintroduces the stale-config overwrite in the actual server path: startServer passes the result of loadConfig() through runOpenAiTierStartupMigration, whose projector always structuredClones the config even on a no-op, so the long-lived config loses the new WeakMap provenance. If the file changes before a later discovery begins, baseline.persisted is false and this branch mutates the stale live object instead of refusing it; an operator's persisted enable can therefore become disabled in memory and be written back by an unrelated save. Preserve/transfer provenance through startup transformations, or require explicit synthetic provenance rather than inferring it from filePath === undefined.

Useful? React with 👍 / 👎.

return true;
}
// A file-loaded instance remains file-backed after inventory drift. The locked
// freshness gate below refuses stale rows without creating unsaved live disables.
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;
}
}
Loading
Loading