diff --git a/docs-site/src/content/docs/fr/guides/model-routing.md b/docs-site/src/content/docs/fr/guides/model-routing.md
index 273e2d19011..0b368488cbc 100644
--- a/docs-site/src/content/docs/fr/guides/model-routing.md
+++ b/docs-site/src/content/docs/fr/guides/model-routing.md
@@ -125,3 +125,9 @@ Le routage et la visibilité dans le catalogue sont deux mécanismes distincts :
un fournisseur portant ce nom (par exemple `anthropic` ou `groq`) est effectivement configuré.
Consultez [Configuration](/fr/reference/configuration/) pour connaître les champs de fournisseur lus par ces règles.
+
+
+
+## Valeurs par défaut de la découverte de modèles
+
+`modelDiscovery.newModelPolicy` vaut `"off"` par défaut, même si le champ est absent. À la première découverte réussie, seul le `defaultModel` configuré est visible ; sinon, activez les modèles nécessaires dans Models. Les sélections, interrupteurs, petites références existantes et catalogues statiques sont conservés. Un ancien catalogue en direct sans sélection comptant au moins 20 modèles et un modèle par défaut configuré est réduit une seule fois à ce dernier ; sans modèle par défaut, sa visibilité est conservée ; les choix manuels ultérieurs ne sont pas réinitialisés. Les nouveaux modèles restent masqués jusqu’à leur activation. Un `"on"` explicite autorise la publication automatique. Utilisez `ocx models new-policy off` (avec `--provider ` si nécessaire) et `ocx models new-arrivals --json`.
diff --git a/docs-site/src/content/docs/fr/reference/configuration/providers.md b/docs-site/src/content/docs/fr/reference/configuration/providers.md
index ce494fc42c9..241b15259e0 100644
--- a/docs-site/src/content/docs/fr/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/fr/reference/configuration/providers.md
@@ -112,7 +112,7 @@ sauvegarde dont le contenu diffère, puis réécrit en identifiants sans préfix
| `defaultModel?` | `string` | Modèle utilisé lorsque ce fournisseur est sélectionné sans modèle explicite. |
| `models?` | `string[]` | Liste initiale ou de repli. Avec `liveModels: false`, une liste `models` non vide est suivie de `retainModels` ; si `models` est vide ou absent, la liste commence par `defaultModel` (si configuré), puis `retainModels`, en conservant la première occurrence de chaque identifiant. |
| `liveModels?` | `boolean` | Récupère le catalogue actif au démarrage et lors de la synchronisation (true par défaut). Les fournisseurs personnalisés utilisent `${baseUrl}/models` ; les fournisseurs intégrés peuvent employer une URL de registre et un filtre. |
-| `selectedModels?` | `string[]` | Liste autorisée du catalogue après la découverte. Non vide expose uniquement ces identifiants ; vide ou omis expose tous les modèles découverts. |
+| `selectedModels?` | `string[]` | Liste autorisée après découverte. Non vide : seuls ces identifiants sont admis. Vide ou absent : aucune liste supplémentaire. La politique de découverte et `disabledModels` restent applicables. |
| `contextWindow?` | `number` | Repli contextuel à l’échelle du fournisseur lorsque les métadonnées en amont sont absentes ; sinon, un plafond qui conserve des métadonnées en direct plus petites. Le tableau de bord Modèles expose cela séparément de `providerContextCaps`. |
| `modelContextWindows?` | `Record` | Valeurs de repli ou plafonds de contexte par modèle. Ils remplacent `contextWindow` : une fenêtre inconnue utilise la valeur configurée, tandis que des métadonnées actives plus faibles restent déterminantes. |
| `inferContextWindow?` | `boolean` | Fournisseurs personnalisés uniquement, désactivé par défaut. `true` complète une fenêtre absente à partir des métadonnées embarquées du modèle ; c’est une estimation, pas la capacité vérifiée du relais. |
diff --git a/docs-site/src/content/docs/guides/model-routing.md b/docs-site/src/content/docs/guides/model-routing.md
index 772a078eb74..9c7e8bc5731 100644
--- a/docs-site/src/content/docs/guides/model-routing.md
+++ b/docs-site/src/content/docs/guides/model-routing.md
@@ -82,18 +82,7 @@ Routing and catalog visibility are separate controls:
for that model.
- A provider's non-empty `selectedModels` is another catalog allowlist. Live discovery and direct
routing still work; only catalog and `/v1/models` emission are narrowed.
-- 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.
- If the running provider configuration differs from disk, model lists still apply the policy
- using a temporary projection; they leave the running configuration and saved choices untouched.
- Use `ocx models new-policy off` globally, add `--provider ` 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
- deliberately inert for them.
+- `modelDiscovery.newModelPolicy` defaults to `"off"`, including when omitted in an existing configuration. On the first successful live discovery, only the configured `defaultModel` starts visible; without a matching default, enable the models you need in **Models**. Saved model selections, per-model switches and short existing baselines are preserved. An existing unselected live catalog with at least 20 models and a configured default is narrowed to that default once; existing baselines without a default retain their visibility; later manual changes are not reset. Static/manual catalogs are unchanged. Later arrivals remain hidden until enabled. Explicit `"on"` opts into automatic exposure. This policy applies before `/v1/models`, client exports and Codex catalog publication; failed discovery changes no choices. Use `ocx models new-policy off` (optionally `--provider `) and `ocx models new-arrivals --json`.
- `provider.disabled: true` removes that provider from catalog discovery. Explicit
`provider/model` requests fail, and `defaultModel` / `models[]` scans skip it.
- Routed catalog rows are cloned from the Codex-native template, so native-only delivery flags are
diff --git a/docs-site/src/content/docs/ja/guides/model-routing.md b/docs-site/src/content/docs/ja/guides/model-routing.md
index 900185715a3..1f3d2137c9a 100644
--- a/docs-site/src/content/docs/ja/guides/model-routing.md
+++ b/docs-site/src/content/docs/ja/guides/model-routing.md
@@ -83,15 +83,7 @@ model ID は変更しません。`openai-apikey/` は API key transport
ブロックしません。
- プロバイダーの `selectedModels` が空でなければカタログ許可リストとして動作します。ライブモデル探索と
直接ルーティングはそのままに、カタログと `/v1/models` に公開するモデルだけ絞ります。
-- `modelDiscovery.newModelPolicy` が `"off"` の場合、最初の成功したライブ探索で基準を確立し、
- その後に追加されたモデルを `disabledModels` に入れます。これは `/v1/models`、ダッシュボード、
- クライアント設定のエクスポート、サービス起動時を含む Codex カタログ同期で公開する前に
- 適用されます。手動で有効化したモデルは、その後の更新やエクスポートでも有効なままです。
- 新規インストールの既定値は `"off"`、既存のインストールは変更するまで `"on"` です。
- `ocx models new-policy off` で全体に適用し、`--provider ` で個別に上書きします。
- `ocx models new-arrivals [--json]` で追加モデルを確認できます。失敗または縮退した探索では
- 基準は変わりません。`selectedModels` が空でないプロバイダーはすでに選別済みのため、
- このポリシーによる自動無効化は行いません。
+- `modelDiscovery.newModelPolicy` の既定値は `"off"` です。既存の設定で省略した場合も同じです。最初の正常なライブ探索では、設定済みの `defaultModel` のみを表示します。一致する既定モデルがない場合は **Models** で必要なモデルを有効にしてください。保存済みの選択、個別スイッチ、短い既存の探索基準、静的カタログは保持します。未選択の既存ライブ一覧が20件以上で既定モデルが設定済みなら一度だけそのモデルに絞ります。既定モデルのない既存の探索基準は表示状態を保持し、その後の手動変更は初期化しません。新着モデルも手動で有効にするまで非表示です。明示的な `"on"` は自動公開を許可します。公開前に適用し、探索失敗時は選択を変更しません。`ocx models new-policy off` と `ocx models new-arrivals --json` を使用できます。
- `provider.disabled: true` のプロバイダーはカタログ探索から除外されます。明示的 `provider/model` リクエストは
失敗し、`defaultModel` / `models[]` 検査でもスキップします。
- `providerContextCaps` はプロバイダーごとに Codex に表示するコンテキスト上限を指定します。
diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md
index 0a6a8e3a203..42e6f48ebc5 100644
--- a/docs-site/src/content/docs/ja/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md
@@ -104,7 +104,7 @@ account を削除しても mapping は保持され、同じ id を再追加す
| `defaultModel?` | `string` |このプロバイダーが明示的なモデルなしで選択された場合に使用されるモデル。 |
| `models?` | `string[]` | 初期/フォールバックモデル一覧。`liveModels: false` で `models` が空でなければ、その後に `retainModels` を追加します。`models` が空または省略されている場合は、設定済みの `defaultModel`、`retainModels` の順に初期一覧を作り、重複 ID は最初の出現だけを残します。 |
| `liveModels?` | `boolean` |開始/同期時にライブ カタログをフェッチします (デフォルトは `true`)。カスタムプロバイダーは `${baseUrl}/models` を使用します。組み込みはレジストリ URL とフィルターを使用する場合があります。 |
-| `selectedModels?` | `string[]` |検出後のカタログ許可リスト。空でない場合は、それらの ID のみが公開されます。空または省略すると、検出されたすべてのモデルが公開されます。 |
+| `selectedModels?` | `string[]` | 探索後の許可リスト。非空の場合は指定 ID のみを許可し、空または省略時は追加の許可リストを適用しません。探索ポリシーと `disabledModels` は引き続き適用されます。 |
| `modelDisplayNames?` | `Record` | このプロバイダーの正確なネイティブモデル ID をキーにした、永続的な表示専用ラベルです。大文字と小文字は区別されます。ラベルはプロバイダーカタログのメタデータより優先され、認証、アダプター、ルーティング、課金、上流リクエストには影響しません。マップは検出上限と同じ 2,000 件までです。 |
| `contextWindow?` | `number` | アップストリームのメタデータが無い場合に使うプロバイダー全体のコンテキスト値。メタデータがある場合は上限として働き、より小さいライブ値をそのまま残します。Models ダッシュボードでは `providerContextCaps` とは別に設定します。 |
| `modelContextWindows?` | `Record` | モデルごとのコンテキスト値および上限。`contextWindow` より優先され、ウィンドウが不明なら設定値を使い、より小さいライブメタデータがあればそちらが優先されます。 |
diff --git a/docs-site/src/content/docs/ko/guides/model-routing.md b/docs-site/src/content/docs/ko/guides/model-routing.md
index d20f8320c7d..4b5f184e340 100644
--- a/docs-site/src/content/docs/ko/guides/model-routing.md
+++ b/docs-site/src/content/docs/ko/guides/model-routing.md
@@ -82,15 +82,7 @@ fallback하지 않습니다.
막지는 않습니다.
- 프로바이더의 `selectedModels`가 비어 있지 않으면 카탈로그 허용 목록으로 동작합니다. 실시간 모델 탐색과
직접 라우팅은 그대로 두고, 카탈로그와 `/v1/models`에 내보낼 모델만 줄입니다.
-- `modelDiscovery.newModelPolicy`가 `"off"`이면 첫 실시간 탐색 성공 시 기준을 만들고,
- 이후 추가된 모델을 `disabledModels`에 넣습니다. 이 정책은 `/v1/models`, 대시보드,
- 클라이언트 설정 내보내기, 서비스 시작을 포함한 Codex 카탈로그 동기화에서 새 모델을 공개하기
- 전에 적용됩니다. 수동으로 활성화한 모델은 이후 새로고침과 내보내기에서도 활성 상태를 유지합니다.
- 새 설치의 기본값은 `"off"`이며, 기존 설치는 직접 바꾸기 전까지 `"on"`을 유지합니다.
- `ocx models new-policy off`로 전역 정책을 설정하고, `--provider `으로 프로바이더별
- 정책을 지정합니다. `ocx models new-arrivals [--json]`으로 새 모델을 확인할 수 있습니다.
- 실패하거나 제한된 탐색은 기준을 바꾸지 않습니다. `selectedModels`가 비어 있지 않은
- 프로바이더는 이미 선택 목록을 사용하므로 이 정책으로 모델을 자동 비활성화하지 않습니다.
+- `modelDiscovery.newModelPolicy`의 기본값은 `"off"`이며, 기존 설정에서 생략한 경우에도 적용됩니다. 첫 실시간 탐색 성공 시 설정된 `defaultModel`만 표시합니다. 일치하는 기본 모델이 없으면 **모델**에서 필요한 모델을 직접 켜세요. 기존 선택 목록, 모델별 켜기/끄기 설정과 짧은 탐색 기준, 수동·정적 카탈로그는 보존합니다. 선택 설정이 없는 기존 실시간 카탈로그가 20개 이상이고 기본 모델이 설정되어 있으면 한 번만 기본 모델로 줄입니다. 기본 모델이 없는 기존 탐색 기준은 표시 상태를 보존하며, 이후 수동 변경은 다시 초기화하지 않습니다. 이후 발견된 모델도 직접 켜기 전까지 숨깁니다. 명시적 `"on"`은 자동 노출을 허용합니다. `/v1/models`, 클라이언트 내보내기, Codex 카탈로그 공개 전에 적용하며, 탐색 실패 시 선택을 바꾸지 않습니다. `ocx models new-policy off`에 `--provider `을 추가해 공급자별로 설정하고, `ocx models new-arrivals --json`으로 새 모델을 확인합니다.
- `provider.disabled: true`인 프로바이더는 카탈로그 탐색에서 제외됩니다. 명시적 `provider/model` 요청은
실패하고, `defaultModel` / `models[]` 검사에서도 건너뜁니다.
- `providerContextCaps`는 공급자별로 Codex에 표시할 컨텍스트 상한을 지정합니다.
diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md
index 5ab948ef710..e3b823d0e9b 100644
--- a/docs-site/src/content/docs/ko/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md
@@ -104,7 +104,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정
| `defaultModel?` | `string` | 이 공급자를 선택할 때 모델을 따로 지정하지 않으면 사용하는 모델입니다. |
| `models?` | `string[]` | 초기/폴백 모델 목록입니다. `liveModels: false`에서 `models`가 비어 있지 않으면 `models`, `retainModels` 순으로 구성합니다. `models`가 비어 있거나 생략되면 설정된 `defaultModel`, `retainModels` 순으로 구성하고, 중복 ID는 처음 나온 항목만 남깁니다. |
| `liveModels?` | `boolean` | 시작 또는 동기화 시 라이브 카탈로그를 가져옵니다. 기본값은 `true`입니다. 사용자 지정 공급자는 `${baseUrl}/models`를 사용하고, 내장은 레지스트리 URL을 사용한 뒤 필터링할 수 있습니다. |
-| `selectedModels?` | `string[]` | 발견 후 카탈로그 허용 목록입니다. 값이 비어 있지 않으면 그 id만 노출하고, 비어 있거나 생략하면 발견된 모델을 모두 노출합니다. |
+| `selectedModels?` | `string[]` | 발견 후 카탈로그 허용 목록입니다. 값이 있으면 그 id만 허용하며, 비어 있거나 생략하면 추가 허용 목록을 적용하지 않습니다. 탐색 정책과 `disabledModels`는 계속 적용됩니다. |
| `modelDisplayNames?` | `Record` | 이 공급자의 정확한 네이티브 모델 id를 키로 쓰는 영구 표시 전용 이름입니다. 키는 대소문자를 구분합니다. 이름은 공급자 카탈로그 메타데이터보다 우선하며 인증, 어댑터, 라우팅, 청구 또는 업스트림 요청을 바꾸지 않습니다. 맵은 발견 한도와 같은 최대 2,000개 항목을 가질 수 있습니다. |
| `contextWindow?` | `number` | 업스트림 메타데이터가 없을 때 쓰이는 공급자 전반의 컨텍스트 값입니다. 메타데이터가 있으면 상한으로 동작해 더 작은 라이브 값을 그대로 둡니다. Models 대시보드에서 `providerContextCaps`와 별도로 설정합니다. |
| `modelContextWindows?` | `Record` | 모델별 컨텍스트 값이자 상한입니다. `contextWindow`보다 우선하며, 창 크기를 알 수 없으면 설정값을 쓰고 더 작은 라이브 메타데이터가 있으면 그쪽을 따릅니다. |
diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md
index 35c4956d357..f0dea9263f8 100644
--- a/docs-site/src/content/docs/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/reference/configuration/providers.md
@@ -322,7 +322,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity
| `defaultModel?` | `string` | Model used when this provider is selected without an explicit model. |
| `models?` | `string[]` | Seed/fallback model list. With `liveModels: false`, a nonempty `models` list is followed by `retainModels`; an empty or omitted `models` list instead seeds `defaultModel` (if configured), then `retainModels`, removing duplicate ids in first-seen order. |
| `liveModels?` | `boolean` | Fetch the live catalog on start/sync (default `true`). Custom providers use `${baseUrl}/models`; built-ins may use a registry URL and filter. |
-| `selectedModels?` | `string[]` | Catalog allowlist after discovery. Non-empty exposes only those ids; empty or omitted exposes all discovered models. |
+| `selectedModels?` | `string[]` | Post-discovery allowlist. Non-empty exposes only those ids; empty or omitted adds no allowlist. Discovery policy and `disabledModels` still apply. |
| `retainModels?` | `string[]` | Ids kept in the catalog even when live discovery omits them. They need not be repeated in `models`. Empty or omitted keeps today's behavior. |
| `modelDisplayNames?` | `Record` | Durable labels used only for display, keyed by this provider's exact upstream model id. Labels win over provider catalog metadata, survive discovery refreshes and provider edits, and never change authentication, adapter behavior, routing, billing, upstream request construction, the routed `provider/model` selector, or the upstream wire model. Keys are exact and case sensitive. Unknown model ids are kept so a temporarily missing model receives its label when it returns. The map accepts at most 2,000 entries, matching the discovery limit. |
| `contextWindow?` | `number` | Provider-wide context fallback when upstream metadata is absent; otherwise a cap that retains smaller live metadata. The Models dashboard exposes this separately from `providerContextCaps`. |
diff --git a/docs-site/src/content/docs/ru/guides/model-routing.md b/docs-site/src/content/docs/ru/guides/model-routing.md
index 117802629dc..3c0e0b1d353 100644
--- a/docs-site/src/content/docs/ru/guides/model-routing.md
+++ b/docs-site/src/content/docs/ru/guides/model-routing.md
@@ -88,15 +88,7 @@ description: Как opencodex решает, какой провайдер буд
- Непустой `selectedModels` провайдера — ещё один список разрешённых для каталога. Живое
обнаружение и прямая маршрутизация продолжают работать; сужается только вывод в каталог и
`/v1/models`.
-- При `modelDiscovery.newModelPolicy: "off"` первое успешное живое обнаружение задаёт базовый
- список; последующие новые модели добавляются в `disabledModels` до публикации в `/v1/models`,
- дашборде, экспорте конфигурации клиентов и синхронизации каталога Codex, включая запуск сервиса.
- Модель, включённая вручную, остаётся включённой при последующих обновлениях и экспортах.
- Новые установки используют `"off"`; существующие сохраняют `"on"`, пока пользователь не изменит
- политику. `ocx models new-policy off` задаёт общую политику; `--provider ` переопределяет
- её для провайдера. `ocx models new-arrivals [--json]` показывает новые модели. Неудачное или
- деградировавшее обнаружение не меняет базовый список. Для провайдеров с непустым `selectedModels`
- автоматическое отключение по этой политике не применяется: их список уже отобран.
+- Значение `modelDiscovery.newModelPolicy` по умолчанию — `"off"`, в том числе при отсутствии поля в существующей конфигурации. При первом успешном обнаружении виден только настроенный `defaultModel`; иначе включите нужные модели в **Models**. Сохранённый выбор, переключатели, небольшие базовые списки и статические каталоги сохраняются. Существующий живой каталог без выбора из 20 и более моделей с настроенной моделью по умолчанию сужается до неё один раз; без такой модели видимость существующего базового списка сохраняется; последующие ручные изменения не сбрасываются. Новые модели скрыты до ручного включения. Явное `"on"` разрешает автоматическую публикацию. Ошибка обнаружения не меняет выбор. Команды: `ocx models new-policy off` и `ocx models new-arrivals --json`.
- `provider.disabled: true` убирает провайдера из обнаружения каталога. Явные запросы
`provider/model` завершаются ошибкой, а проверки `defaultModel` / `models[]` его пропускают.
- `providerContextCaps` задаёт видимые для Codex лимиты контекста по провайдерам.
diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md
index 251087a87b2..9120210cc9e 100644
--- a/docs-site/src/content/docs/ru/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md
@@ -117,7 +117,7 @@ cross-route credential fallback не существует. Строки API GPT-
| `defaultModel?` | `string` | Модель, используемая когда этот провайдер выбран без явной модели. |
| `models?` | `string[]` | Начальный список моделей или список для резервного режима. При `liveModels: false` за непустым `models` следует `retainModels`; если `models` пуст или отсутствует, сначала берётся настроенный `defaultModel`, затем `retainModels`. Для каждого ID сохраняется только первое вхождение. |
| `liveModels?` | `boolean` | Получать live-каталог на start/sync (по умолчанию `true`). Custom-провайдеры используют `${baseUrl}/models`; built-in могут использовать registry URL и дополнительно фильтровать результат. |
-| `selectedModels?` | `string[]` | Allowlist каталога после discovery. Непустой список показывает только эти id; пустой или отсутствующий показывает всё, что было обнаружено. |
+| `selectedModels?` | `string[]` | Список разрешённых ID после обнаружения. Непустой список допускает только эти ID; пустой или отсутствующий не добавляет ограничений. Политика обнаружения и `disabledModels` продолжают действовать. |
| `modelDisplayNames?` | `Record` | Постоянные display-only имена с точным нативным id модели этого провайдера в качестве ключа. Ключи чувствительны к регистру. Имена имеют приоритет над metadata каталога провайдера и не меняют аутентификацию, adapter, routing, billing или upstream-запросы. Карта содержит не более 2 000 записей, как и discovery. |
| `contextWindow?` | `number` | Значение контекста для всего провайдера, применяемое когда upstream не отдаёт metadata; при наличии metadata работает как cap и сохраняет более маленькое live-значение. Панель Models настраивает его отдельно от `providerContextCaps`. |
| `modelContextWindows?` | `Record` | Значения и cap'ы контекста по отдельным моделям. Перекрывают `contextWindow`: если окно неизвестно, берётся заданное значение, а более маленькая live-metadata остаётся авторитетной. |
diff --git a/docs-site/src/content/docs/tr/guides/model-routing.md b/docs-site/src/content/docs/tr/guides/model-routing.md
index c3183bdcb6d..e98246458f3 100644
--- a/docs-site/src/content/docs/tr/guides/model-routing.md
+++ b/docs-site/src/content/docs/tr/guides/model-routing.md
@@ -99,6 +99,7 @@ Yönlendirme ve katalog görünürlüğü ayrı kontrollerdir:
- Bir sağlayıcının boş olmayan `selectedModels` alanı başka bir katalog izin
listesidir. Canlı keşif ve doğrudan yönlendirme hala çalışır; yalnızca katalog
ve `/v1/models` yayını daraltılır.
+- **Model keşfi varsayılanları:** `modelDiscovery.newModelPolicy` varsayılanı `"off"` değeridir; alan yoksa da geçerlidir. İlk başarılı canlı keşifte yalnızca ayarlanan `defaultModel` görünür; eşleşme yoksa Models bölümünde gereken modelleri açın. Seçimler, anahtarlar, küçük mevcut temel listeler ve statik kataloglar korunur. Seçim yapılmamış en az 20 modellik eski canlı katalog, yapılandırılmış bir varsayılan model varsa bir kez o modele daraltılır; varsayılanı olmayan mevcut temel listelerin görünürlüğü korunur; sonraki elle değişiklikler sıfırlanmaz. Yeni modeller elle açılana kadar gizlidir. Açık `"on"` otomatik yayıma izin verir. `ocx models new-policy off` (isteğe bağlı `--provider `) ve `ocx models new-arrivals --json` komutlarını kullanın.
- `provider.disabled: true`, bu sağlayıcıyı katalog keşfinden kaldırır. Açık
`sağlayıcı/model` istekleri başarısız olur ve `defaultModel` / `models[]`
taramaları bunu atlar.
@@ -140,5 +141,3 @@ Yönlendirme ve katalog görünürlüğü ayrı kontrollerdir:
Bu kuralların okuduğu sağlayıcı alanları için
[Yapılandırma](/tr/reference/configuration/) sayfasına bakın.
-
-
diff --git a/docs-site/src/content/docs/tr/reference/configuration/providers.md b/docs-site/src/content/docs/tr/reference/configuration/providers.md
index 580c2d64ea9..416bd0e802c 100644
--- a/docs-site/src/content/docs/tr/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/tr/reference/configuration/providers.md
@@ -117,7 +117,7 @@ alanlı seçilmiş kimlikleri yalın kimliklere yeniden yazar.
| `defaultModel?` | `string` | Bu sağlayıcı açık bir model olmadan seçildiğinde kullanılan model. |
| `models?` | `string[]` | Başlangıç/geri dönüş model listesi. `liveModels: false` iken boş olmayan `models` listesini `retainModels` izler; `models` boşsa veya atlanmışsa önce yapılandırılmış `defaultModel`, sonra `retainModels` kullanılır. Yinelenen kimliklerin yalnızca ilk geçtiği konum korunur. |
| `liveModels?` | `boolean` | Başlatmada/senkronizasyonda canlı kataloğu getirin (varsayılan `true`). Özel sağlayıcılar `${baseUrl}/models` kullanır; yerleşikler bir kayıt defteri URL'si ve filtresi kullanabilir. |
-| `selectedModels?` | `string[]` | Keşiften sonra katalog izin listesi. Boş olmaması yalnızca bu kimlikleri gösterir; boş veya atlanmış olması keşfedilen tüm modelleri gösterir. |
+| `selectedModels?` | `string[]` | Keşif sonrası izin listesi. Doluysa yalnızca bu kimliklere izin verir; boş veya eksikse ek izin listesi uygulanmaz. Keşif ilkesi ve `disabledModels` geçerlidir. |
| `contextWindow?` | `number` | Yukarı akış meta verileri olmadığında sağlayıcı genelinde bağlam geri dönüşü; aksi takdirde daha küçük canlı meta verileri koruyan bir sınır. Modeller kontrol paneli bunu `providerContextCaps` alanından ayrı olarak gösterir. |
| `modelContextWindows?` | `Record` | Model başına bağlam geri dönüşleri/sınırları. Bunlar `contextWindow`'u geçersiz kılar: bilinmeyen bir pencere yapılandırılmış değeri kullanırken, daha küçük canlı meta veriler yetkili kalır. |
| `inferContextWindow?` | `boolean` | Yalnızca özel sağlayıcılar, varsayılan kapalı. `true`, eksik pencereyi yerleşik model meta verisinden doldurur; bu bir tahmindir, aracının doğrulanmış kapasitesi değildir. |
diff --git a/docs-site/src/content/docs/zh-cn/guides/model-routing.md b/docs-site/src/content/docs/zh-cn/guides/model-routing.md
index 1b51859ec35..0c4e18314d6 100644
--- a/docs-site/src/content/docs/zh-cn/guides/model-routing.md
+++ b/docs-site/src/content/docs/zh-cn/guides/model-routing.md
@@ -72,13 +72,7 @@ transport;这些凭证路径互不 fallback。
仍保留在目录中,但会改为 `visibility: "hide"`。它**不会**拒绝对该模型的直接请求。
- 提供商的非空 `selectedModels` 是另一层目录 allowlist。实时发现和直接路由仍然有效;它只会缩小
目录和 `/v1/models` 输出的模型范围。
-- 当 `modelDiscovery.newModelPolicy` 为 `"off"` 时,首次成功的实时发现建立基准,后续新增
- 模型会加入 `disabledModels`。此策略会在通过 `/v1/models`、仪表盘、客户端配置导出或
- Codex 目录同步(包括服务启动)发布新模型之前生效。手动启用的模型在后续刷新和导出中仍保持
- 启用。新安装默认使用 `"off"`,已有安装在主动更改前保持 `"on"`。使用
- `ocx models new-policy off` 设置全局策略,添加 `--provider ` 覆盖单个提供商的设置;
- 使用 `ocx models new-arrivals [--json]` 查看新增模型。失败或降级的发现不会改变基准。
- 非空 `selectedModels` 已经限定了提供商的模型列表,因此此策略不会再自动禁用模型。
+- `modelDiscovery.newModelPolicy` 默认为 `"off"`,现有配置省略此字段时也一样。首次成功的实时发现只显示配置的 `defaultModel`;没有匹配的默认模型时,请在 **Models** 中启用所需模型。保留已有选择列表、单模型开关、较短的发现基准和静态目录。未设置选择的现有实时目录达到20个模型且已配置默认模型时,只执行一次默认模型收窄;没有默认模型的现有发现基准保留原本的可见性,之后不重置手动更改。新发现的模型在手动启用前保持隐藏。显式 `"on"` 允许自动公开。该策略在发布目录前应用,发现失败不改变选择。使用 `ocx models new-policy off` 和 `ocx models new-arrivals --json` 管理。
- `provider.disabled: true` 会把该提供商排除在目录发现之外。显式 `provider/model` 请求会失败,
`defaultModel` / `models[]` 扫描也会跳过它。
- `providerContextCaps` 为各提供商设置 Codex 可见的上下文上限。`contextCapValue` 是仪表盘的默认值,
diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md
index 6503f875d74..7864ba34aa1 100644
--- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md
@@ -106,7 +106,7 @@ selector,而不是分配一个新名称。
| `defaultModel?` | `string` | 当选择该提供者但未显式指定模型时使用的模型。 |
| `models?` | `string[]` | 初始/回退模型列表。`liveModels: false` 时,非空 `models` 后接 `retainModels`;若 `models` 为空或省略,则按已配置的 `defaultModel`、`retainModels` 顺序构建初始列表,重复 ID 仅保留首次出现的位置。 |
| `liveModels?` | `boolean` | 启动/同步时获取实时目录(默认 `true`)。自定义提供者使用 `${baseUrl}/models`;内置项可能使用注册表 URL 并进行过滤。 |
-| `selectedModels?` | `string[]` | 发现之后的目录允许列表。非空时只暴露这些 id;为空或省略时则暴露全部发现到的模型。 |
+| `selectedModels?` | `string[]` | 发现后的允许列表。非空时仅允许这些 id;为空或省略时不添加允许列表。发现策略和 `disabledModels` 仍然适用。 |
| `modelDisplayNames?` | `Record` | 持久的仅显示名称,以此提供者的精确原生模型 id 为键。键区分大小写。名称优先于提供者目录元数据,并且不会改变身份验证、适配器、路由、计费或上游请求。该映射最多可包含 2,000 个条目,与发现上限相同。 |
| `contextWindow?` | `number` | 上游缺少元数据时使用的提供者级上下文数值;有元数据时作为上限,保留更小的实时数值。Models 面板中与 `providerContextCaps` 分开设置。 |
| `modelContextWindows?` | `Record` | 按模型设置的上下文数值与上限。优先于 `contextWindow`:窗口未知时采用所配置的数值,而更小的实时元数据仍然优先。 |
diff --git a/docs-site/src/content/docs/zh-tw/guides/model-routing.md b/docs-site/src/content/docs/zh-tw/guides/model-routing.md
index 0a716351eea..e9145f3c8d3 100644
--- a/docs-site/src/content/docs/zh-tw/guides/model-routing.md
+++ b/docs-site/src/content/docs/zh-tw/guides/model-routing.md
@@ -80,3 +80,9 @@ OpenAI 的 bare `gpt-*` 使用單一 `openai` provider。`codexAccountMode` 在
- **字首模式只是一種便利**,而非保證:只有當確實設定了同名(例如 `anthropic`、`openai`、`groq`)的供應商時,它們才會解析成功。
這些規則讀取的供應商欄位請參見 [設定](/zh-tw/reference/configuration/)。
+
+
+
+## 模型探索預設值
+
+`modelDiscovery.newModelPolicy` 預設為 `"off"`,省略此欄位時也相同。首次成功的即時探索只顯示設定的 `defaultModel`;沒有相符預設值時,請在 Models 啟用需要的模型。保留既有選擇清單、個別開關、較短的探索基準與靜態目錄。未設定選擇的既有即時目錄若有20個以上模型且已設定預設模型,只執行一次預設模型縮減;沒有預設模型的既有探索基準保留原本的可見性,之後不重設手動變更。新模型在手動啟用前隱藏。明確的 `"on"` 允許自動公開。 使用 `ocx models new-policy off`(可加上 `--provider `)與 `ocx models new-arrivals --json`。
diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md
index 1d04c0b1cdb..517fb89e04e 100644
--- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md
+++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md
@@ -83,7 +83,7 @@ ocx models provider openrouter on
| `defaultModel?` | `string` | 在未指定明確模型時選擇此供應商所使用的模型。 |
| `models?` | `string[]` | 初始/後備模型列表。`liveModels: false` 時,非空 `models` 後接 `retainModels`;若 `models` 為空或省略,則按已設定的 `defaultModel`、`retainModels` 順序建立初始列表,重複 ID 僅保留首次出現的位置。 |
| `liveModels?` | `boolean` | 在啟動/同步時擷取即時目錄(預設 `true`)。自訂供應商使用 `${baseUrl}/models`;內建可能使用 registry URL 並過濾。 |
-| `selectedModels?` | `string[]` | 探索後的目錄允許清單。非空時僅暴露那些 id;空或省略時暴露所有探索的模型。 |
+| `selectedModels?` | `string[]` | 探索後的允許清單。非空時僅允許這些 id;空白或省略時不增加允許清單。探索政策及 `disabledModels` 仍然適用。 |
| `contextWindow?` | `number` | 供應商範圍的 Codex 可見 context 上限。較小的即時中繼資料被保留。 |
| `modelContextWindows?` | `Record` | Per-model context 上限。這些覆寫 `contextWindow` 且永不提高較小的即時中繼資料。 |
| `inferContextWindow?` | `boolean` | 僅限自訂供應商,預設關閉。設為 `true` 時以內建模型中繼資料補足缺少的視窗;這是估計值,不代表中轉的實測容量。 |
diff --git a/src/providers/new-model-policy.ts b/src/providers/new-model-policy.ts
index 5c29ec758b1..329d64a3b17 100644
--- a/src/providers/new-model-policy.ts
+++ b/src/providers/new-model-policy.ts
@@ -1,5 +1,6 @@
import type { OcxConfig } from "../types";
import { routedSlug, slugEquals } from "./slug-codec";
+import { INITIAL_MODEL_SELECTION_THRESHOLD } from "./initial-model-selection";
import {
antigravityEffortFamilyIds, antigravityFamilyDisabled,
type AntigravityEffortFamilyRow,
@@ -41,6 +42,7 @@ export interface NewModelPolicyResult {
*/
function baselineDiffers(prior: KnownModelBaseline | undefined, next: KnownModelBaseline): boolean {
if (!prior) return true;
+ if (prior.visibilityInitialized !== next.visibilityInitialized) return true;
const sameList = (a: readonly string[], b: readonly string[]) =>
a.length === b.length && a.every((value, index) => value === b[index]);
if (!sameList(prior.ids, next.ids) || !sameList(prior.removed, next.removed)) return true;
@@ -51,25 +53,32 @@ function baselineDiffers(prior: KnownModelBaseline | undefined, next: KnownModel
return false;
}
-/** Pure successful-discovery transition. An absent baseline bootstraps without hiding anything. */
+/** Pure successful-discovery transition; callers may supply the initial visible choices. */
export function applyNewModelPolicy(options: {
provider: string;
discoveredIds: Iterable;
baseline?: KnownModelBaseline;
policy: NewModelPolicy;
hasSelectedModels?: boolean;
+ /** Configured default on first discovery; omitted when preserving existing switch choices. */
+ initiallyVisibleIds?: Iterable;
now: string;
/** See {@link NewModelPolicyMode}; defaults to `converge`. */
mode?: NewModelPolicyMode;
}): NewModelPolicyResult {
const discovered = [...new Set(options.discoveredIds)].sort();
const prior = options.baseline;
+ const visible = options.initiallyVisibleIds === undefined ? null : new Set(options.initiallyVisibleIds);
if (!prior) {
+ const overflow = discovered.length > MAX_KNOWN_MODELS_PER_PROVIDER;
return {
newIds: [],
- nextBaseline: { ids: discovered, removed: [], updatedAt: options.now },
- slugsToDisable: [], arrivals: [],
- overflow: discovered.length > MAX_KNOWN_MODELS_PER_PROVIDER,
+ nextBaseline: { ids: discovered, removed: [], updatedAt: options.now,
+ ...(visible !== null ? { visibilityInitialized: true } : {}) },
+ slugsToDisable: !overflow && options.policy === "off" && !options.hasSelectedModels && visible !== null
+ ? discovered.filter(id => !visible.has(id)).map(id => routedSlug(options.provider, id)) : [],
+ arrivals: [],
+ overflow,
};
}
const active = new Set(prior.ids);
@@ -102,12 +111,14 @@ export function applyNewModelPolicy(options: {
newIds,
nextBaseline: {
ids: [...active].sort(), removed: [...removed].sort(), updatedAt: options.now,
+ ...(prior.visibilityInitialized || options.initiallyVisibleIds !== undefined ? { visibilityInitialized: true } : {}),
...(Object.keys(missing).length ? { missing } : {}),
},
// A non-empty preset/custom allowlist already excludes arrivals. This explicit no-op is
// deliberate: preset mode owns which matching flagships arrive on.
slugsToDisable: !overflow && options.policy === "off" && !options.hasSelectedModels
- ? newIds.map(id => routedSlug(options.provider, id)) : [],
+ ? (visible === null ? newIds : discovered.filter(id => !visible.has(id)))
+ .map(id => routedSlug(options.provider, id)) : [],
arrivals,
overflow,
};
@@ -116,7 +127,7 @@ export function applyNewModelPolicy(options: {
export function effectiveNewModelPolicy(config: OcxConfig, provider: string): NewModelPolicy {
const local = config.providers[provider]?.newModelPolicy;
if (local === "on" || local === "off") return local;
- return config.modelDiscovery?.newModelPolicy ?? "on";
+ return config.modelDiscovery?.newModelPolicy ?? "off";
}
/** Normalize policy identities only; the original rows and saved wire selections stay intact. */
@@ -147,7 +158,10 @@ function normalizeEffortFamilies(config: OcxConfig, provider: string, rows: Anti
baseline = { ...prior, ids: normalizeIds(prior.ids), removed: normalizeIds(prior.removed),
...(Object.keys(missing).length ? { missing } : {}) };
}
- return { discoveredIds: normalizeIds(rows.map(row => row.id)), baseline, inheritedDisables };
+ const defaultModel = config.providers[provider]?.defaultModel;
+ return { discoveredIds: normalizeIds(rows.map(row => row.id)), baseline, inheritedDisables,
+ switchCandidateIds: [...new Set([...rows.map(row => row.id), ...aliases.keys(), ...(prior?.ids ?? [])])],
+ defaultModel: defaultModel ? normalize(defaultModel) : undefined };
}
/** Apply authoritative provider rows to a mutable convergence copy; degraded providers are omitted. */
@@ -169,7 +183,7 @@ export function reconcileSuccessfulModelDiscoveries(options: {
for (const provider of options.authoritativeProviders) {
const configured = options.config.providers[provider];
if (!configured || configured.liveModels === false) continue;
- const { discoveredIds, baseline, inheritedDisables } = normalizeEffortFamilies(
+ const { discoveredIds, baseline, inheritedDisables, defaultModel, switchCandidateIds } = normalizeEffortFamilies(
options.config, provider, byProvider.get(provider) ?? [],
);
const discovery = options.config.modelDiscovery ??= {};
@@ -178,10 +192,20 @@ export function reconcileSuccessfulModelDiscoveries(options: {
provider, discoveredIds, baseline,
policy: effectiveNewModelPolicy(options.config, provider),
hasSelectedModels: (configured.selectedModels?.length ?? 0) > 0,
+ // A previous per-model switch edit is an explicit choice too. Never reinitialize it.
+ initiallyVisibleIds: baseline?.visibilityInitialized
+ || (baseline && baseline.ids.length < INITIAL_MODEL_SELECTION_THRESHOLD)
+ || (baseline && !defaultModel)
+ || (configured.modelPreset?.mode === "all" && !configured.modelPreset.fallback)
+ || options.config.disabledModels?.some(selector => switchCandidateIds.some(id => slugEquals(selector, provider, id)))
+ ? undefined : defaultModel ? [defaultModel] : [],
now: options.now,
mode: options.mode,
});
if (result.overflow) continue;
+ // Preserving an existing roster is a completed visibility decision too. A later default
+ // edit or growth past the bootstrap threshold must not reinitialize those choices.
+ result.nextBaseline.visibilityInitialized = true;
const priorBaseline = known[provider];
const baselineChanged = baselineDiffers(priorBaseline, result.nextBaseline);
known[provider] = result.nextBaseline;
diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts
index 2b6586c9a94..db0e1aad3bd 100644
--- a/src/server/management/model-routes.ts
+++ b/src/server/management/model-routes.ts
@@ -227,7 +227,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise 0) config.providers[provider].selectedModels = models;
- else delete config.providers[provider].selectedModels;
+ else {
+ delete config.providers[provider].selectedModels;
+ config.providers[provider].modelPreset = { mode: "all" };
+ }
// Divergence is detected at the WRITE path, not by diffing (#2465): a user edit while the
// provider is in preset mode makes the selection theirs, and the proxy must never
// re-materialize over it afterwards.
diff --git a/src/types/config.ts b/src/types/config.ts
index 955522cf1fe..66afd56a8e9 100644
--- a/src/types/config.ts
+++ b/src/types/config.ts
@@ -561,13 +561,15 @@ export interface OcxConfig {
managementUsageMaxReadBytes?: number;
providers: Record;
defaultProvider: string;
- /** Persisted state for newly discovered provider models (#2464). Absent keeps legacy "on" behavior. */
+ /** Persisted discovery choices; new models default off, with the configured default visible at bootstrap. */
modelDiscovery?: {
newModelPolicy?: "on" | "off";
knownModels?: Record;
}>;
diff --git a/structure/catalog.md b/structure/catalog.md
index 60a4225768a..415efe9aa8d 100644
--- a/structure/catalog.md
+++ b/structure/catalog.md
@@ -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` excludes disabled providers, including custom rows. `src/codex/catalog/gather-capture.ts` leaves disabled providers and every `adapter: "jev-decision"` decision-service row out of the gather flight, so a decision row's `defaultModel` or `models` never becomes a routable catalog row and its endpoint is never probed for `/models`. `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. File-backed configs retain their load provenance when their inventory diverges from disk. Read callers can opt into a detached policy projection when drift predates discovery: management renders its disabled rows, while other shared-fetch consumers receive only its visible rows. The projection copies discovery state and disables, leaving live config, disk, and the live merge baseline untouched. Writer callers still refuse stale publication; reload or explicit adoption is required before retained sync can commit. Inventory changes during discovery, superseded cache revisions, changed config homes, and failed persistence still refuse publication. 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), and `tests/codex-integration/model-visibility-management-api.test.ts` (drifted read projection).
+`src/codex/catalog/model-visibility.ts` excludes disabled providers, including custom rows. `src/codex/catalog/gather-capture.ts` leaves disabled providers and every `adapter: "jev-decision"` decision-service row out of the gather flight, so a decision row's `defaultModel` or `models` never becomes a routable catalog row and its endpoint is never probed for `/models`. `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. File-backed configs retain their load provenance when their inventory diverges from disk. Read callers can opt into a detached policy projection when drift predates discovery: management renders its disabled rows, while other shared-fetch consumers receive only its visible rows. The projection copies discovery state and disables, leaving live config, disk, and the live merge baseline untouched. Writer callers still refuse stale publication; reload or explicit adoption is required before retained sync can commit. Inventory changes during discovery, superseded cache revisions, changed config homes, and failed persistence still refuse publication. Discovery absorbs arrivals/reappearances without advancing removal grace; convergence retains removal accounting. An omitted new-model policy resolves to off. On the first authoritative live discovery, only the configured default starts visible; an absent or unavailable default leaves every discovered row off. Unselected legacy live catalogs of at least 20 models with a configured default also receive that one-time projection; existing baselines without a default retain their visibility; `visibilityInitialized` in the discovery baseline records both narrowed and preserved visibility, so later default edits or roster growth cannot reinitialize prior choices, including enabling every row. Short existing baselines (measured before newly discovered arrivals), per-model switch choices (including temporarily missing active IDs and original Antigravity wire tiers, but not stale retired IDs), non-empty allowlists, explicit all-mode presets, static catalogs, custom rows and degraded discovery retain their choices. Explicit all-mode writes retain their marker rather than masquerading as an absent selection. Later arrivals stay off until explicitly enabled. 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), and `tests/codex-integration/model-visibility-management-api.test.ts` (drifted read projection).
Custom rows replace discovered rows that encode to the same Codex-facing slug. For a provider whose captured discovery declares an order (`preferFirst`), `src/codex/catalog/routed-gather.ts` puts the replacement in the discovered row's slot; every other provider keeps discovered rows first and custom rows after them, as before. Coverage: `tests/codex-integration/catalog-custom-ordering.test.ts`.
diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md
index 7eb489e8e4e..e647586ae0b 100644
--- a/structure/providers-and-adapters.md
+++ b/structure/providers-and-adapters.md
@@ -589,3 +589,5 @@ remains authoritative. No raw body is retained in that field, serialized into cl
Presence of both `detail` and `error` across the original root and its own non-array `response` record is ambiguous, regardless of values; null counts and deeper quoted objects do not. This is checked before selecting an error carrier.
For complete bounded HTTP errors, the text wrapper selects root `detail`/`error` when present; otherwise it selects the own `response` record for the same exact `detail` or `error.message` match. It never searches another carrier after a nonmatch or descends further. Policy-fallback and direct callers share this wrapper; SSE message parsing disables nested HTTP selection. The same bounded HTTP parser retains allowlisted dedicated `code` fields from root and response before display truncation; diagnostic `type` is never promoted, including when code is absent or malformed. Policy/direct classification inspects full text only without authoritative carrier metadata; SSE never borrows hard codes from quoted message JSON.
Committed output, cancellation, hard policy refusals and resend limits remain stronger than model fallback.
+
+`src/providers/new-model-policy.ts` resolves an omitted discovery policy to off. First authoritative live discovery records automatic disables before publication and keeps only the configured default visible, unless an allowlist, per-model switch choice or explicit all-mode preset already owns visibility. Switch choices cover current wire identities and active baseline IDs before effort-family normalization; retired-only IDs are excluded. Unselected legacy live catalogs of at least 20 rows with a configured default receive the same projection once; existing baselines without a default retain their visibility, recorded by `visibilityInitialized`; smaller prior baselines retain their choices. Preserved baselines also receive the initialization marker, so later default edits and roster growth never trigger another visibility bootstrap. Static/manual catalogs and degraded discovery are unchanged.
diff --git a/tests/ci-workflows/release-preflight.test.ts b/tests/ci-workflows/release-preflight.test.ts
index e53989bb6f8..1f0c5630146 100644
--- a/tests/ci-workflows/release-preflight.test.ts
+++ b/tests/ci-workflows/release-preflight.test.ts
@@ -10,7 +10,7 @@
* `scripts/ci/release-preflight.sh` against a real tag set with fake `gh` and `npm`.
*/
import { describe, expect, test } from "bun:test";
-import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
+import { mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { removeTreeWithRetry } from "../helpers/remove-tree";
@@ -125,6 +125,9 @@ function preflight(scenario: Scenario): { status: number | null; output: string;
const bin = join(directory, "bin");
mkdirSync(repo);
mkdirSync(bin);
+ // Other isolated workers can alter ambient PATH. Run the real preflight with this Bun,
+ // never a temporary fake package's relocated executable (whose dylibs may be absent).
+ symlinkSync(process.execPath, join(bin, "bun"));
writeFileSync(join(bin, "gh"), FAKE_GH, { mode: 0o755 });
writeFileSync(join(bin, "npm"), FAKE_NPM, { mode: 0o755 });
const gitEnv = {
diff --git a/tests/claude-integration/claude-desktop-discovery.test.ts b/tests/claude-integration/claude-desktop-discovery.test.ts
index edc50927248..1c5ef30f4df 100644
--- a/tests/claude-integration/claude-desktop-discovery.test.ts
+++ b/tests/claude-integration/claude-desktop-discovery.test.ts
@@ -186,7 +186,7 @@ describe("Desktop snapshot through authenticated model discovery", () => {
port: 0, hostname: "0.0.0.0", defaultProvider: "test", runtimeRole: "hub",
...(pickerOrder ? { modelPickerOrder: pickerOrder, subagentModels: [], subagentModelsVersion: SUBAGENT_MODELS_VERSION } : {}),
providers: {
- test: { adapter: "openai-chat", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, apiKey: "fixture", allowPrivateNetwork: true, models: ["model-123", "model-155"], reasoningEfforts: ["low", "medium", "high", "xhigh", "max"] },
+ test: { adapter: "openai-chat", baseUrl: `http://127.0.0.1:${upstream.port}/v1`, apiKey: "fixture", allowPrivateNetwork: true, models: ["model-123", "model-155"], selectedModels: ["model-123", "model-155"], reasoningEfforts: ["low", "medium", "high", "xhigh", "max"] },
},
claudeCode: {
enabled, desktopNativeModels: false,
diff --git a/tests/claude-integration/claude-desktop-remote-hub.test.ts b/tests/claude-integration/claude-desktop-remote-hub.test.ts
index ec75eecc15f..0e7b3d743a5 100644
--- a/tests/claude-integration/claude-desktop-remote-hub.test.ts
+++ b/tests/claude-integration/claude-desktop-remote-hub.test.ts
@@ -5,6 +5,7 @@ import { delimiter, dirname, isAbsolute, join } from "node:path";
import { tmpdir } from "node:os";
import type { OcxConfig } from "../../src/types";
import type { Desktop3pModelEntry } from "../../src/claude/desktop-3p";
+import { NATIVE_OPENAI_MODELS } from "../../src/codex/catalog/native-models";
import { repoPath, fixturePath } from "../helpers/repo-root";
import { removeTreeWithRetry } from "../helpers/remove-tree";
import { SPAWN_BUDGET_MS } from "../helpers/test-budget";
@@ -195,6 +196,9 @@ for (const storedProfile of [true, false]) {
defaultProvider: "decoy", codexAutoStart: false, syncResumeHistory: false,
clientIntegrations: { codex: false, grok: false, "claude-desktop": false },
providers: { chosen: provider(chosen, "model-target"), decoy: provider(decoy, "model-decoy") },
+ // Keep one native candidate: background runtime discovery may reorder the native roster
+ // between snapshot and apply, but this case tests hub IDs rather than native priority.
+ disabledModels: NATIVE_OPENAI_MODELS.filter(id => id !== "gpt-5.5"),
subagentModels: ["decoy/model-decoy", "chosen/model-target"],
claudeCode: {
enabled: true, nativePassthrough: false, desktopNativeModels: true,
diff --git a/tests/cli/cli-connect-readiness.test.ts b/tests/cli/cli-connect-readiness.test.ts
index c46913c9576..bd68eb1ec91 100644
--- a/tests/cli/cli-connect-readiness.test.ts
+++ b/tests/cli/cli-connect-readiness.test.ts
@@ -174,9 +174,11 @@ function runStatusProbe(options: {
runtimeEnv.LOCALAPPDATA = join(opencodexHome, "local-app-data");
runtimeEnv.FIXTURE_RUNTIME_DIRS = JSON.stringify({ selected: selectedDir, lower: lowerDir, rejected: rejectedDir });
runtimeEnv.FIXTURE_FULL_DIAGNOSTICS = options.fullDiagnostics ? "1" : "0";
+ // This fixture tests fallback to a saved choice, not discovery upgrades. An unpinned
+ // record can legitimately hand over to a newer system-wide install outside fixture PATH.
if (options.persisted) writeFileSync(join(opencodexHome, "codex-runtime.json"), JSON.stringify({
version: 1, command: selected, source: "configured", selectedVersion: "0.145.0",
- updatedAt: "2026-08-28T00:00:00.000Z",
+ updatedAt: "2026-08-28T00:00:00.000Z", origin: "pinned",
}));
}
diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts
index 01018f767e8..354caa5f189 100644
--- a/tests/codex-integration/codex-catalog.test.ts
+++ b/tests/codex-integration/codex-catalog.test.ts
@@ -7796,7 +7796,7 @@ describe("#2465 model preset management routes", () => {
const config = presetConfig(["anthropic/claude-opus-5"], { mode: "preset", appliedVersion: 1 });
await call(config, "PUT", { provider: "openrouter", mode: "all" });
expect(config.providers.openrouter.selectedModels).toBeUndefined();
- expect(config.providers.openrouter.modelPreset).toBeUndefined();
+ expect(config.providers.openrouter.modelPreset).toEqual({ mode: "all" });
});
test("a preset matching nothing never writes an empty allowlist", async () => {
diff --git a/tests/codex-integration/codex-composed-acceptance.test.ts b/tests/codex-integration/codex-composed-acceptance.test.ts
index b383c7a35c4..89f59a08130 100644
--- a/tests/codex-integration/codex-composed-acceptance.test.ts
+++ b/tests/codex-integration/codex-composed-acceptance.test.ts
@@ -239,6 +239,11 @@ class Fixture {
if (existsSync(path)) throw new Error(`lock preflight found pre-existing case path: ${path}`);
}
writeFileSync(join(this.codex, "config.toml"), 'model = "gpt-5"\n');
+ // PATH isolation cannot hide fixed install roots. A harmless launcher keeps the host's
+ // Codex from creating its own tmp artifacts during background bundled-catalog discovery.
+ writeFileSync(join(this.provider, process.platform === "win32" ? "codex.cmd" : "codex"),
+ process.platform === "win32" ? "@echo off\r\necho codex-cli 0.145.0\r\n"
+ : "#!/bin/sh\nprintf '%s\\n' 'codex-cli 0.145.0'\n", { mode: 0o755 });
const serviceHome = claimOwnedServiceHome(this.codex, this.ocx, this.homeA);
this.serviceManagerEnv = serviceHome.env;
this.serviceManagerPreloadPath = serviceHome.preloadPath;
@@ -261,6 +266,7 @@ class Fixture {
// installation on every platform instead of reporting not_installed.
GROK_HOME: join(home, ".grok"),
CODEX_HOME: this.codex,
+ CODEX_CLI_PATH: join(this.provider, process.platform === "win32" ? "codex.cmd" : "codex"),
OPENCODEX_HOME: this.ocx,
XDG_RUNTIME_DIR: this.runtime,
OPENCODEX_API_AUTH_TOKEN: this.dataToken,
diff --git a/tests/codex-integration/codex-retained-root-serialization.test.ts b/tests/codex-integration/codex-retained-root-serialization.test.ts
index 22f5d11ed98..232133d9d67 100644
--- a/tests/codex-integration/codex-retained-root-serialization.test.ts
+++ b/tests/codex-integration/codex-retained-root-serialization.test.ts
@@ -405,6 +405,7 @@ for (const publisher of ["convergence", "retained"] as const) {
apiKey: "fixture-key",
allowPrivateNetwork: true,
liveModels: true,
+ newModelPolicy: "on",
},
},
disabledModels: ["gpt-5.5"],
diff --git a/tests/codex-integration/codex-runtime.test.ts b/tests/codex-integration/codex-runtime.test.ts
index 5991c05f7cf..66868144ebb 100644
--- a/tests/codex-integration/codex-runtime.test.ts
+++ b/tests/codex-integration/codex-runtime.test.ts
@@ -862,11 +862,11 @@ describe("resolveCodexRuntime", () => {
const deps = { env: { PATH: "" }, discoverAlternatives: false };
try {
- const cached = resolveCodexRuntime(deps);
- expect(cached.runtime.source).toBe("fallback");
+ // This case compares absent versions, not host installation discovery. Seed the
+ // observed selection directly so system-wide Codex installs cannot replace it.
setCodexRuntimeResolveCacheForTests({
runtime: { command: "codex", version: null, source: "environment" },
- failures: cached.failures,
+ failures: [],
}, deps);
const before = readFileSync(statePath, "utf8");
diff --git a/tests/providers/initial-selection-write-fence.test.ts b/tests/providers/initial-selection-write-fence.test.ts
index a40d7c85afa..79cb1f2b2d2 100644
--- a/tests/providers/initial-selection-write-fence.test.ts
+++ b/tests/providers/initial-selection-write-fence.test.ts
@@ -15,11 +15,11 @@ import { removeTreeWithRetry } from "../helpers/remove-tree";
const ids = ["anthropic/claude-opus-5", "openai/gpt-5.6-sol"];
const operations = [
- { path: "/api/model-presets", input: { mode: "all" }, selected: undefined, mode: undefined },
+ { path: "/api/model-presets", input: { mode: "all" }, selected: undefined, mode: "all" },
{ path: "/api/model-presets", input: { mode: "custom" }, selected: [ids[0]], mode: "custom" },
{ path: "/api/model-presets", input: { mode: "preset" }, selected: ids, mode: "preset" },
{ path: "/api/selected-models", input: { models: [ids[1]] }, selected: [ids[1]], mode: "custom" },
- { path: "/api/selected-models", input: { models: [] }, selected: undefined, mode: "custom" },
+ { path: "/api/selected-models", input: { models: [] }, selected: undefined, mode: "all" },
] as const;
let home: string;
let previousHome: string | undefined;
diff --git a/tests/providers/new-model-policy-runtime.test.ts b/tests/providers/new-model-policy-runtime.test.ts
index fbc3fca4eb1..fd0bb1690bb 100644
--- a/tests/providers/new-model-policy-runtime.test.ts
+++ b/tests/providers/new-model-policy-runtime.test.ts
@@ -15,7 +15,7 @@ function fixture(): OcxConfig {
return { port: 10100, defaultProvider: "vendor", providers: { vendor: {
adapter: "openai-chat", baseUrl: "https://fixture.invalid/v1", models: ["a", "b"], newModelPolicy: "off",
} }, disabledModels: ["vendor/b"], modelDiscovery: {
- knownModels: { vendor: { ids: ["a", "b"], removed: [], updatedAt: stamp } },
+ knownModels: { vendor: { ids: ["a", "b"], removed: [], updatedAt: stamp, visibilityInitialized: true } },
} };
}
const bytes = () => readFileSync(home.path("config.json"), "utf8");
@@ -29,6 +29,23 @@ afterEach(() => {
home.remove();
});
+test("first discovery persists default-only visibility before publication", () => {
+ const initial = fixture();
+ delete initial.disabledModels;
+ delete initial.modelDiscovery;
+ delete initial.providers.vendor.newModelPolicy;
+ initial.providers.vendor.defaultModel = "a";
+ saveConfig(initial);
+ const config = loadConfig();
+ expect(complete(config)).toBe(true);
+ expect(loadConfig().disabledModels).toEqual(["vendor/b", "vendor/c"]);
+ const enabled = loadConfig();
+ enabled.disabledModels = ["vendor/c"];
+ saveConfig(enabled);
+ expect(complete(config)).toBe(true);
+ expect(config.disabledModels).toEqual(["vendor/c"]);
+});
+
test("discovery persists arrival and disable together, then preserves a manual enable without rewriting", () => {
saveConfig(fixture());
const config = loadConfig();
diff --git a/tests/providers/new-model-policy.test.ts b/tests/providers/new-model-policy.test.ts
index eec3166a177..13a108a0301 100644
--- a/tests/providers/new-model-policy.test.ts
+++ b/tests/providers/new-model-policy.test.ts
@@ -9,6 +9,116 @@ import {
const now = "2026-08-24T02:11:00Z";
describe("new-model policy", () => {
+ test("an unconfigured policy keeps a 600-model discovery out of the picker", () => {
+ const config = {
+ port: 10100, defaultProvider: "vendor",
+ providers: { vendor: { defaultModel: "model-3" } },
+ } as Parameters[0]["config"];
+ const models = Array.from({ length: 600 }, (_, i) => ({ provider: "vendor", id: `model-${i}` }));
+ expect(effectiveNewModelPolicy(config, "vendor")).toBe("off");
+ reconcileSuccessfulModelDiscoveries({ config, models, authoritativeProviders: ["vendor"], now });
+ expect(config.disabledModels).toHaveLength(599);
+ expect(config.disabledModels).not.toContain("vendor/model-3");
+ // A manual choice survives repeated discovery and a new arrival remains off.
+ config.disabledModels = config.disabledModels!.filter(id => id !== "vendor/model-7");
+ reconcileSuccessfulModelDiscoveries({ config, models: [...models, { provider: "vendor", id: "new" }], authoritativeProviders: ["vendor"], now });
+ expect(config.disabledModels).not.toContain("vendor/model-7");
+ expect(config.disabledModels).toContain("vendor/new");
+ });
+
+ test("bootstrap without a default exposes nothing; explicit selections and all-on remain authoritative", () => {
+ for (const provider of [{}, { selectedModels: ["a"] }, { newModelPolicy: "on" as const }]) {
+ const config = { port: 10100, defaultProvider: "vendor", providers: { vendor: provider } } as Parameters[0]["config"];
+ reconcileSuccessfulModelDiscoveries({ config, models: [{ provider: "vendor", id: "a" }, { provider: "vendor", id: "b" }], authoritativeProviders: ["vendor"], now });
+ expect(config.disabledModels ?? []).toEqual("selectedModels" in provider || "newModelPolicy" in provider ? [] : ["vendor/a", "vendor/b"]);
+ }
+ });
+
+ test("bootstrap preserves explicit model switches, static catalogs, and degraded discovery", () => {
+ for (const mode of ["switches", "static", "degraded"] as const) {
+ const config = { port: 10100, defaultProvider: "vendor", providers: { vendor: {
+ ...(mode === "static" ? { liveModels: false } : {}),
+ } }, ...(mode === "switches" ? { disabledModels: ["vendor/b"] } : {}) } as Parameters[0]["config"];
+ reconcileSuccessfulModelDiscoveries({ config, models: [{ provider: "vendor", id: "a" }, { provider: "vendor", id: "b" }], authoritativeProviders: mode === "degraded" ? [] : ["vendor"], now });
+ expect(config.disabledModels ?? []).toEqual(mode === "switches" ? ["vendor/b"] : []);
+ }
+ });
+
+ test("a legacy unselected 600-model baseline is narrowed once, without resetting later choices", () => {
+ const models = Array.from({ length: 600 }, (_, i) => ({ provider: "vendor", id: `model-${i}` }));
+ const config = { port: 10100, defaultProvider: "vendor", providers: { vendor: { defaultModel: "model-3" } },
+ modelDiscovery: { knownModels: { vendor: { ids: models.map(row => row.id), removed: [], updatedAt: now } } },
+ } as Parameters[0]["config"];
+ reconcileSuccessfulModelDiscoveries({ config, models, authoritativeProviders: ["vendor"], now });
+ expect(config.disabledModels).toHaveLength(599);
+ expect(config.disabledModels).not.toContain("vendor/model-3");
+ config.disabledModels = [];
+ expect(reconcileSuccessfulModelDiscoveries({ config, models, authoritativeProviders: ["vendor"], now })).toBe(false);
+ expect(config.disabledModels).toEqual([]);
+ });
+
+ test("a legacy baseline without a default preserves choices across later default edits", () => {
+ const ids = Array.from({ length: 600 }, (_, i) => `model-${i}`);
+ const config = { port: 10100, defaultProvider: "vendor", providers: { vendor: {} },
+ modelDiscovery: { knownModels: { vendor: { ids, removed: [], updatedAt: now } } },
+ } as Parameters[0]["config"];
+ const models = ids.map(id => ({ provider: "vendor", id }));
+ reconcileSuccessfulModelDiscoveries({ config, models, authoritativeProviders: ["vendor"], now });
+ expect(config.disabledModels ?? []).toEqual([]);
+ expect(config.modelDiscovery!.knownModels!.vendor.visibilityInitialized).toBe(true);
+ config.providers.vendor!.defaultModel = "model-3";
+ expect(reconcileSuccessfulModelDiscoveries({ config, models, authoritativeProviders: ["vendor"], now })).toBe(false);
+ expect(config.disabledModels ?? []).toEqual([]);
+ reconcileSuccessfulModelDiscoveries({ config, models: [...models, { provider: "vendor", id: "new" }], authoritativeProviders: ["vendor"], now });
+ expect(config.disabledModels).toEqual(["vendor/new"]);
+ });
+
+ test.each([false, true])("a short baseline survives threshold growth (previously reconciled: %s)", previouslyReconciled => {
+ const models = Array.from({ length: 21 }, (_, i) => ({ provider: "vendor", id: `model-${i}` }));
+ const config = { port: 10100, defaultProvider: "vendor", providers: { vendor: { defaultModel: "model-3" } },
+ modelDiscovery: { knownModels: { vendor: { ids: models.slice(0, 19).map(row => row.id), removed: [], updatedAt: now } } },
+ } as Parameters[0]["config"];
+ if (previouslyReconciled) {
+ reconcileSuccessfulModelDiscoveries({ config, models: models.slice(0, 19), authoritativeProviders: ["vendor"], now });
+ expect(config.disabledModels ?? []).toEqual([]);
+ expect(config.modelDiscovery!.knownModels!.vendor.visibilityInitialized).toBe(true);
+ }
+ reconcileSuccessfulModelDiscoveries({ config, models, authoritativeProviders: ["vendor"], now });
+ expect(config.disabledModels).toEqual(["vendor/model-19", "vendor/model-20"]);
+ expect(config.modelDiscovery!.knownModels!.vendor.visibilityInitialized).toBe(true);
+ });
+
+ test.each(["active", "retired"] as const)("a switch for a missing %s model controls legacy initialization", state => {
+ const models = Array.from({ length: 600 }, (_, i) => ({ provider: "vendor", id: `model-${i}` }));
+ const config = { port: 10100, defaultProvider: "vendor", providers: { vendor: { defaultModel: "model-3" } },
+ disabledModels: ["vendor/missing"], modelDiscovery: { knownModels: { vendor: {
+ ids: [...models.map(row => row.id), ...(state === "active" ? ["missing"] : [])],
+ removed: state === "retired" ? ["missing"] : [], updatedAt: now,
+ } } },
+ } as Parameters[0]["config"];
+ reconcileSuccessfulModelDiscoveries({ config, models, authoritativeProviders: ["vendor"], now, mode: "discovery" });
+ expect(config.disabledModels).toHaveLength(state === "active" ? 1 : 600);
+ expect(config.disabledModels).toContain("vendor/missing");
+ expect(config.disabledModels).not.toContain("vendor/model-3");
+ expect(config.modelDiscovery!.knownModels!.vendor.visibilityInitialized).toBe(true);
+ });
+
+ test("a legacy Antigravity wire-tier switch survives normalization before initialization", () => {
+ const provider = "google-antigravity";
+ const base = "claude-opus-5-5";
+ const models = [...Array.from({ length: 20 }, (_, i) => ({ provider, id: `model-${i}` })), {
+ provider, id: base, antigravityEffortWireModelIds: { low: `${base}-low`, medium: `${base}-medium`, high: `${base}-high` },
+ }];
+ const config = { port: 10100, defaultProvider: provider, providers: { [provider]: { defaultModel: "model-3" } },
+ disabledModels: [`${provider}/${base}-high`], modelDiscovery: { knownModels: { [provider]: {
+ ids: [...models.slice(0, 20).map(row => row.id), `${base}-low`, `${base}-high`], removed: [], updatedAt: now,
+ } } },
+ } as Parameters[0]["config"];
+ reconcileSuccessfulModelDiscoveries({ config, models, authoritativeProviders: [provider], now });
+ expect(config.disabledModels).toEqual([`${provider}/${base}-high`]);
+ expect(config.modelDiscovery!.knownModels![provider].visibilityInitialized).toBe(true);
+ });
+
test("bootstraps without hiding the existing catalog", () => {
const r = applyNewModelPolicy({ provider: "openrouter", discoveredIds: ["a", "b"], policy: "off", now });
expect(r.newIds).toEqual([]); expect(r.slugsToDisable).toEqual([]); expect(r.nextBaseline.ids).toEqual(["a", "b"]);
@@ -65,7 +175,7 @@ describe("new-model policy", () => {
port: 10100, defaultProvider: "vendor", providers: { vendor: {} },
modelDiscovery: {
newModelPolicy: "off" as const,
- knownModels: { vendor: { ids: ["a", "b"], removed: [], updatedAt: "2026-01-01T00:00:00Z" } },
+ knownModels: { vendor: { ids: ["a", "b"], removed: [], updatedAt: "2026-01-01T00:00:00Z", visibilityInitialized: true } },
},
};
const changed = reconcileSuccessfulModelDiscoveries({
@@ -154,7 +264,7 @@ describe("new-model policy", () => {
test("repeated read-side reconciliation of the same roster never shrinks the baseline or reports a change", () => {
const config = {
port: 10100, defaultProvider: "vendor", providers: { vendor: {} },
- modelDiscovery: { newModelPolicy: "off" as const, knownModels: { vendor: structuredClone(seedBaseline) } },
+ modelDiscovery: { newModelPolicy: "off" as const, knownModels: { vendor: { ...structuredClone(seedBaseline), visibilityInitialized: true } } },
} as Parameters[0]["config"];
for (let poll = 0; poll < MODEL_REMOVAL_GRACE_FETCHES * 3; poll++) {
const changed = reconcileSuccessfulModelDiscoveries({
diff --git a/tests/providers/opencode-cli.test.ts b/tests/providers/opencode-cli.test.ts
index 1a682fecfa8..d2ccad56f16 100644
--- a/tests/providers/opencode-cli.test.ts
+++ b/tests/providers/opencode-cli.test.ts
@@ -333,6 +333,7 @@ describe("ocx opencode proxy model catalog", () => {
baseUrl: "https://proxyenv.test/v1",
apiKey: `\${${ENV_KEY}}`,
models: ["static-fallback"],
+ selectedModels: ["static-fallback", "live-via-proxy-env"],
// Discovery runs on the pinned transport; hand it back the stub above.
fetch: ((input: RequestInfo | URL, init?: RequestInit) => globalThis.fetch(input, init)) as typeof fetch,
},
diff --git a/tests/providers/provider-antigravity-effort-families.test.ts b/tests/providers/provider-antigravity-effort-families.test.ts
index 0b5ff073ae2..065a518d802 100644
--- a/tests/providers/provider-antigravity-effort-families.test.ts
+++ b/tests/providers/provider-antigravity-effort-families.test.ts
@@ -20,7 +20,7 @@ function configFor(ids = tiers): OcxConfig {
port: 10100, defaultProvider: provider, providers: { [provider]: {} },
modelDiscovery: {
newModelPolicy: "off",
- knownModels: { [provider]: { ids: [...ids], removed: [], updatedAt: now } },
+ knownModels: { [provider]: { ids: [...ids], removed: [], updatedAt: now, visibilityInitialized: true } },
},
};
}
@@ -162,7 +162,7 @@ describe("Antigravity effort family reconciliation", () => {
config.modelDiscovery!.knownModels![provider]!.removed = [tiers[1]!];
config.modelDiscovery!.knownModels![provider]!.missing = { [tiers[0]!]: 2, [tiers[2]!]: 1 };
expect(reconcile(config)).toBe(true);
- expect(config.modelDiscovery!.knownModels![provider]).toEqual({ ids: [base], removed: [base], updatedAt: now });
+ expect(config.modelDiscovery!.knownModels![provider]).toEqual({ ids: [base], removed: [base], updatedAt: now, visibilityInitialized: true });
expect(config.modelDiscovery!.recentArrivals).toBeUndefined();
expect(reconcile(config)).toBe(false);
});
@@ -234,7 +234,7 @@ test("overlapping complete family bases remain public and are not policy aliases
antigravityEffortWireModelIds: { low: `${id}-low`, medium: `${id}-medium`, high: `${id}-high` } }));
expect(collapseAntigravityPublicModels(rows).map(row => row.id)).toEqual(["foo", "foo-low"]);
const config: OcxConfig = { port: 0, defaultProvider: provider, providers: { [provider]: {} },
- modelDiscovery: { knownModels: { [provider]: { ids: ["foo", "foo-low"], removed: [], updatedAt: now } } } };
+ modelDiscovery: { knownModels: { [provider]: { ids: ["foo", "foo-low"], removed: [], updatedAt: now, visibilityInitialized: true } } } };
expect(reconcileSuccessfulModelDiscoveries({ config, models: rows, authoritativeProviders: [provider], now })).toBe(false);
expect(config.modelDiscovery!.knownModels![provider]!.ids).toEqual(["foo", "foo-low"]);
});
diff --git a/tests/server/management-model-roster-gather-race.test.ts b/tests/server/management-model-roster-gather-race.test.ts
index 00d200597ac..962c3c74edc 100644
--- a/tests/server/management-model-roster-gather-race.test.ts
+++ b/tests/server/management-model-roster-gather-race.test.ts
@@ -35,6 +35,8 @@ function raceConfig(): OcxConfig {
port: 10100,
hostname: "127.0.0.1",
defaultProvider: "alpha",
+ // This suite exercises changing discovery snapshots, with visibility explicitly opted in.
+ modelDiscovery: { newModelPolicy: "on" },
providers: {
alpha: { adapter: "openai-chat", baseUrl: ALPHA_HOST, models: [] },
beta: { adapter: "openai-chat", baseUrl: BETA_HOST, models: [] },
diff --git a/tests/server/server-new-model-policy-arrival.test.ts b/tests/server/server-new-model-policy-arrival.test.ts
index b6ce62457b9..2d8c0979d2f 100644
--- a/tests/server/server-new-model-policy-arrival.test.ts
+++ b/tests/server/server-new-model-policy-arrival.test.ts
@@ -50,6 +50,27 @@ const cases: Array<{
{ name: "inherited global on", global: "on", visible: true },
];
+test("first HTTP discovery with no policy exposes only the configured default", () => lifecycle.run(async () => {
+ const ids = Array.from({ length: 600 }, (_, i) => `model-${i}`);
+ const upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () =>
+ Response.json({ data: ids.map(id => ({ id })) }) });
+ lifecycle.ownStop(() => upstream.stop(true));
+ saveConfig({ port: 0, hostname: "127.0.0.1", defaultProvider: provider,
+ clientIntegrations: { codex: false }, providers: { [provider]: {
+ adapter: "openai-chat", baseUrl: new URL("/v1", upstream.url).href,
+ apiKey: "fixture-key", allowPrivateNetwork: true, liveModels: true, defaultModel: "model-3",
+ } } });
+ const { startServer } = await import("../../src/server");
+ const server = startServer(0);
+ lifecycle.ownStop(() => server.stop(true));
+ const response = await fetch(new URL("/v1/models", server.url), { signal: lifecycle.abort.signal });
+ expect(response.status).toBe(200);
+ const body = await response.json() as { data: Array<{ id: string }> };
+ expect(body.data.map(row => row.id).filter(id => id.startsWith(`${provider}/`)))
+ .toEqual([`${provider}/model-3`]);
+ expect(loadConfig().disabledModels).toHaveLength(599);
+}, SERVER_BUDGET_MS), SERVER_BUDGET_MS);
+
// Discovery is driven through the real HTTP route, never by calling policy reconciliation, so the
// assertions below check the persisted contract under test (#6260): an absorbed baseline, a
// recorded arrival, a surviving manual block, and a manual re-enable that holds across refresh.