Skip to content

Commit 104f29d

Browse files
lidge-juncolthreepv
andcommitted
fix(models): apply new-model policy before discovery, sync and export publication
Carries #6260 with a maintainer fix: a file-loaded config keeps its file provenance after discovery inventory drift, so stale discovery is refused under the mutation coordinator instead of disabling a model in memory that a later unrelated save would persist over the operator's choice. Co-authored-by: colthreepv <2657230+colthreepv@users.noreply.github.com>
1 parent 87e182f commit 104f29d

26 files changed

Lines changed: 1220 additions & 17 deletions

‎docs-site/src/content/docs/guides/model-routing.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ Routing and catalog visibility are separate controls:
8585
- Fresh installs set `modelDiscovery.newModelPolicy` to `"off"`. After the first successful live
8686
fetch establishes a baseline, later arrivals are appended to `disabledModels` and carry a **NEW**
8787
dashboard badge until enabled or acknowledged. Existing installs remain `"on"` until opted in.
88+
The policy applies before publishing newly discovered models through `/v1/models`, the dashboard,
89+
client configuration exports, or a Codex catalog sync (including service startup).
90+
Enabling an arrival manually keeps it enabled on later refreshes and exports.
8891
Use `ocx models new-policy off` globally, add `--provider <name>` for an override, and inspect
8992
`ocx models new-arrivals [--json]`. Failed/degraded fetches never change the baseline. Providers
9093
with a non-empty `selectedModels` (including preset mode) are already curated, so this policy is

‎docs-site/src/content/docs/ja/guides/model-routing.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,15 @@ model ID は変更しません。`openai-apikey/<model>` は API key transport
8383
ブロックしません。
8484
- プロバイダーの `selectedModels` が空でなければカタログ許可リストとして動作します。ライブモデル探索と
8585
直接ルーティングはそのままに、カタログと `/v1/models` に公開するモデルだけ絞ります。
86+
- `modelDiscovery.newModelPolicy` が `"off"` の場合、最初の成功したライブ探索で基準を確立し、
87+
その後に追加されたモデルを `disabledModels` に入れます。これは `/v1/models`、ダッシュボード、
88+
クライアント設定のエクスポート、サービス起動時を含む Codex カタログ同期で公開する前に
89+
適用されます。手動で有効化したモデルは、その後の更新やエクスポートでも有効なままです。
90+
新規インストールの既定値は `"off"`、既存のインストールは変更するまで `"on"` です。
91+
`ocx models new-policy off` で全体に適用し、`--provider <name>` で個別に上書きします。
92+
`ocx models new-arrivals [--json]` で追加モデルを確認できます。失敗または縮退した探索では
93+
基準は変わりません。`selectedModels` が空でないプロバイダーはすでに選別済みのため、
94+
このポリシーによる自動無効化は行いません。
8695
- `provider.disabled: true` のプロバイダーはカタログ探索から除外されます。明示的 `provider/model` リクエストは
8796
失敗し、`defaultModel` / `models[]` 検査でもスキップします。
8897
- `providerContextCaps` はプロバイダーごとに Codex に表示するコンテキスト上限を指定します。

‎docs-site/src/content/docs/ko/guides/model-routing.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ fallback하지 않습니다.
8282
막지는 않습니다.
8383
- 프로바이더의 `selectedModels`가 비어 있지 않으면 카탈로그 허용 목록으로 동작합니다. 실시간 모델 탐색과
8484
직접 라우팅은 그대로 두고, 카탈로그와 `/v1/models`에 내보낼 모델만 줄입니다.
85+
- `modelDiscovery.newModelPolicy`가 `"off"`이면 첫 실시간 탐색 성공 시 기준을 만들고,
86+
이후 추가된 모델을 `disabledModels`에 넣습니다. 이 정책은 `/v1/models`, 대시보드,
87+
클라이언트 설정 내보내기, 서비스 시작을 포함한 Codex 카탈로그 동기화에서 새 모델을 공개하기
88+
전에 적용됩니다. 수동으로 활성화한 모델은 이후 새로고침과 내보내기에서도 활성 상태를 유지합니다.
89+
새 설치의 기본값은 `"off"`이며, 기존 설치는 직접 바꾸기 전까지 `"on"`을 유지합니다.
90+
`ocx models new-policy off`로 전역 정책을 설정하고, `--provider <name>`으로 프로바이더별
91+
정책을 지정합니다. `ocx models new-arrivals [--json]`으로 새 모델을 확인할 수 있습니다.
92+
실패하거나 제한된 탐색은 기준을 바꾸지 않습니다. `selectedModels`가 비어 있지 않은
93+
프로바이더는 이미 선택 목록을 사용하므로 이 정책으로 모델을 자동 비활성화하지 않습니다.
8594
- `provider.disabled: true`인 프로바이더는 카탈로그 탐색에서 제외됩니다. 명시적 `provider/model` 요청은
8695
실패하고, `defaultModel` / `models[]` 검사에서도 건너뜁니다.
8796
- `providerContextCaps`는 공급자별로 Codex에 표시할 컨텍스트 상한을 지정합니다.

‎docs-site/src/content/docs/ru/guides/model-routing.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,15 @@ description: Как opencodex решает, какой провайдер буд
8888
- Непустой `selectedModels` провайдера — ещё один список разрешённых для каталога. Живое
8989
обнаружение и прямая маршрутизация продолжают работать; сужается только вывод в каталог и
9090
`/v1/models`.
91+
- При `modelDiscovery.newModelPolicy: "off"` первое успешное живое обнаружение задаёт базовый
92+
список; последующие новые модели добавляются в `disabledModels` до публикации в `/v1/models`,
93+
дашборде, экспорте конфигурации клиентов и синхронизации каталога Codex, включая запуск сервиса.
94+
Модель, включённая вручную, остаётся включённой при последующих обновлениях и экспортах.
95+
Новые установки используют `"off"`; существующие сохраняют `"on"`, пока пользователь не изменит
96+
политику. `ocx models new-policy off` задаёт общую политику; `--provider <name>` переопределяет
97+
её для провайдера. `ocx models new-arrivals [--json]` показывает новые модели. Неудачное или
98+
деградировавшее обнаружение не меняет базовый список. Для провайдеров с непустым `selectedModels`
99+
автоматическое отключение по этой политике не применяется: их список уже отобран.
91100
- `provider.disabled: true` убирает провайдера из обнаружения каталога. Явные запросы
92101
`provider/model` завершаются ошибкой, а проверки `defaultModel` / `models[]` его пропускают.
93102
- `providerContextCaps` задаёт видимые для Codex лимиты контекста по провайдерам.

‎docs-site/src/content/docs/zh-cn/guides/model-routing.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ transport;这些凭证路径互不 fallback。
7272
仍保留在目录中,但会改为 `visibility: "hide"`。它**不会**拒绝对该模型的直接请求。
7373
- 提供商的非空 `selectedModels` 是另一层目录 allowlist。实时发现和直接路由仍然有效;它只会缩小
7474
目录和 `/v1/models` 输出的模型范围。
75+
- 当 `modelDiscovery.newModelPolicy` 为 `"off"` 时,首次成功的实时发现建立基准,后续新增
76+
模型会加入 `disabledModels`。此策略会在通过 `/v1/models`、仪表盘、客户端配置导出或
77+
Codex 目录同步(包括服务启动)发布新模型之前生效。手动启用的模型在后续刷新和导出中仍保持
78+
启用。新安装默认使用 `"off"`,已有安装在主动更改前保持 `"on"`。使用
79+
`ocx models new-policy off` 设置全局策略,添加 `--provider <name>` 覆盖单个提供商的设置;
80+
使用 `ocx models new-arrivals [--json]` 查看新增模型。失败或降级的发现不会改变基准。
81+
非空 `selectedModels` 已经限定了提供商的模型列表,因此此策略不会再自动禁用模型。
7582
- `provider.disabled: true` 会把该提供商排除在目录发现之外。显式 `provider/model` 请求会失败,
7683
`defaultModel` / `models[]` 扫描也会跳过它。
7784
- `providerContextCaps` 为各提供商设置 Codex 可见的上下文上限。`contextCapValue` 是仪表盘的默认值,

‎scripts/test-layout/layout.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,7 @@
598598
"codex-spark-visibility.test.ts": "codex-integration",
599599
"codex-sqlite-home.test.ts": "codex-integration",
600600
"codex-sync-api.test.ts": "codex-integration",
601+
"codex-sync-new-model-policy.test.ts": "codex-integration",
601602
"codex-sync-response.test.ts": "codex-integration",
602603
"codex-tool-mode.test.ts": "codex-integration",
603604
"codex-transition-state-adoption.test.ts": "codex-integration",
@@ -1080,6 +1081,7 @@
10801081
"management-metrics-export.test.ts": "server",
10811082
"management-model-roster-gather-race.test.ts": "server",
10821083
"management-model-roster.test.ts": "server",
1084+
"model-export-new-model-policy.test.ts": "server",
10831085
"management-origin-tls.test.ts": "server",
10841086
"management-provider-compat-carry.test.ts": "server",
10851087
"management-provider-hide-raw-reasoning.test.ts": "server",
@@ -1112,6 +1114,7 @@
11121114
"model-cache.test.ts": "codex-integration",
11131115
"model-costs-management-api.test.ts": "server",
11141116
"model-discovery-management-api.test.ts": "server",
1117+
"server-new-model-policy-arrival.test.ts": "server",
11151118
"model-display-names-management-api.test.ts": "codex-integration",
11161119
"model-metadata-sync.test.ts": "codex-integration",
11171120
"model-pinned-effort-config.test.ts": "config",
@@ -1159,6 +1162,7 @@
11591162
"native-profile-startup.test.ts": "codex-integration",
11601163
"native-profile-store.test.ts": "codex-integration",
11611164
"new-model-policy.test.ts": "providers",
1165+
"new-model-policy-runtime.test.ts": "providers",
11621166
"nous-oauth-live.test.ts": "providers",
11631167
"nous-oauth.test.ts": "providers",
11641168
"novita-provider.test.ts": "providers",

‎src/codex/catalog/retained-sync.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration
77
import { getCodexHome } from "../paths";
88
import type { OcxConfig } from "../../types";
99
import { pendingModelSelectionProviders } from "../../providers/initial-model-selection";
10+
import { captureModelDiscoveryBaseline, finalizeModelDiscovery } from "../../providers/new-model-policy-runtime";
1011
import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
1112
import { providerCodexAccountMode } from "../../providers/registry";
1213
import { COMBO_NAMESPACE } from "../../combos";
@@ -602,10 +603,13 @@ export async function syncCatalogModels(
602603
evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog),
603604
processEvidence: retainedCatalogProcessEvidence(),
604605
};
606+
const discoveryBaseline = captureModelDiscoveryBaseline(config);
607+
const providerContentRevisions = new Map<string, string>();
605608
const [goModels, modelEntitlements] = await Promise.all([
606609
gatherRoutedModels(config, {
607610
comboOmissions,
608611
providerModelOutcomes,
612+
providerContentRevisions,
609613
}),
610614
resolveAdmittedCodexModelEntitlements(config),
611615
]);
@@ -629,6 +633,11 @@ export async function syncCatalogModels(
629633
const current = revalidateRetainedCatalogSync(config, prepared);
630634
if (current === null) return null;
631635
if (!isCodexModelEntitlementSnapshotCurrent(modelEntitlements)) return null;
636+
// Revalidate before adopting discovery: its scoped config mutation would otherwise
637+
// invalidate our own evidence. K -> C is the catalog/config lock order.
638+
if (!finalizeModelDiscovery(config, discoveryBaseline, goModels,
639+
providerModelOutcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider),
640+
providerContentRevisions)) return null;
632641
return writeRetainedCatalogSync({
633642
config,
634643
goModels,

‎src/config.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
import { refreshConfigDerivedRegistries } from "./config/derived-registries";
1212
import {
1313
clearPendingConfigDeletions,
14+
recordFileBackedConfig,
1415
projectConfigRebaseProvenance,
1516
} from "./config/rebase-provenance";
1617
import { getConfigDir, getConfigPath, hardenConfigDir } from "./config/paths";
@@ -249,7 +250,7 @@ export function loadConfig(): OcxConfig {
249250
warnDegradedCatalogAutoRefresh(parsed);
250251
warnDegradedCodexPool(parsed);
251252
warnDegradedCredentialGroups(parsed);
252-
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
253+
return recordFileBackedConfig(withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)), configPath);
253254
}
254255
// Only object-shaped configs are repairable. Spreading another JSON value
255256
// into defaults can manufacture a valid config and bypass the invalid-file
@@ -285,7 +286,7 @@ export function loadConfig(): OcxConfig {
285286
warnDegradedCatalogAutoRefresh(parsed);
286287
warnDegradedCodexPool(parsed);
287288
warnDegradedCredentialGroups(parsed);
288-
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
289+
return recordFileBackedConfig(withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)), configPath);
289290
}
290291
// Still failing, but if every complaint is about one or more named entries
291292
// in an independent section, drop exactly those and keep the rest. Falling
@@ -315,7 +316,7 @@ export function loadConfig(): OcxConfig {
315316
warnDegradedCatalogAutoRefresh(parsed);
316317
warnDegradedCodexPool(parsed);
317318
warnDegradedCredentialGroups(parsed);
318-
return withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed));
319+
return recordFileBackedConfig(withRefreshedCostOverlays(normalizeClaudeSubagentEffort(normalizeNativeSubagentSync(config, parsed), parsed)), configPath);
319320
}
320321
}
321322
// Merge couldn't fix it — truly broken config

‎src/config/live-reconcile.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,24 @@ const claudeCodeBaseline = new WeakMap<OcxConfig, unknown>();
4141
* reconciliation paths below.
4242
*/
4343
const liveConfigBaseline = new WeakMap<OcxConfig, OcxConfig>();
44+
45+
/**
46+
* Adopt a committed discovery decision and its merge baseline as one synchronous step.
47+
* Visibility writers assign and save synchronously; any future writer yielding between
48+
* those steps must preserve pending edits through a three-way merge here.
49+
*/
50+
export function adoptPersistedModelDiscovery(
51+
config: OcxConfig,
52+
persisted: Pick<OcxConfig, "modelDiscovery" | "disabledModels">,
53+
): void {
54+
config.modelDiscovery = structuredClone(persisted.modelDiscovery);
55+
config.disabledModels = structuredClone(persisted.disabledModels);
56+
const baseline = liveConfigBaseline.get(config);
57+
if (baseline) {
58+
baseline.modelDiscovery = structuredClone(persisted.modelDiscovery);
59+
baseline.disabledModels = structuredClone(persisted.disabledModels);
60+
}
61+
}
4462
/**
4563
* The live config retains the address of the socket Bun actually opened, while
4664
* this map retains the operator's desired address for the next process start.

‎src/config/rebase-provenance.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
import type { OcxConfig } from "../types";
22

3+
// Object provenance survives disk drift without adding serializable config fields.
4+
const fileBackedConfigs = new WeakMap<OcxConfig, string>();
5+
6+
export function recordFileBackedConfig(config: OcxConfig, path: string): OcxConfig {
7+
fileBackedConfigs.set(config, path);
8+
return config;
9+
}
10+
11+
export function fileBackedConfigPath(config: OcxConfig): string | undefined {
12+
return fileBackedConfigs.get(config);
13+
}
14+
315
const pendingTopLevelDeletions = new WeakMap<OcxConfig, Set<string>>();
416
const pendingObjectChildDeletions = new WeakMap<OcxConfig, Map<string, Set<string>>>();
517
export const CONFIG_REBASE_PROVENANCE_KEY = "configRebaseProvenance";

0 commit comments

Comments
 (0)