diff --git a/docs-site/src/content/docs/fr/guides/model-ordering.md b/docs-site/src/content/docs/fr/guides/model-ordering.md index fab755afdf4..95cdd75584f 100644 --- a/docs-site/src/content/docs/fr/guides/model-ordering.md +++ b/docs-site/src/content/docs/fr/guides/model-ordering.md @@ -30,7 +30,7 @@ Sans ordre global du sélecteur, les priorités sans sélecteur pertinentes sont | `subagentModels[i]` | `i` (`0` à `4`) | La carte de classement présentée dans `src/codex/catalog/sync.ts` | | Autres modèles acheminés | `5` | Création d'une entrée routé dans `src/codex/catalog/sync.ts` | | Modèles routés non mis en avant et présents dans `modelPickerOrder` | `1000 + i` | Rang d’affichage du sélecteur dans `src/codex/catalog/sync.ts` | -| Slugs GPT natifs par défaut | `9` | Création d'entrées natives dans `src/codex/catalog/sync.ts` | +| Slugs GPT natifs par défaut | `sourcePriority` | Création d'entrées natives dans `src/codex/catalog/sync.ts` | | Modèles natifs non sélectionnés alors qu'une liste sélectionnée existe | Au moins `featured.length + 100` | Fusion du catalogue natif dans `src/codex/catalog/sync.ts` | La direction API limite `subagentModels` à cinq entrées avec `slice(0, 5)` en @@ -73,7 +73,7 @@ Sans sélecteur de compte éligible et sans liste de sélection non vide, l'ordr 3. Modèles natifs non sélectionnés, poussés sous le bloc sélectionné lors de la fusion du catalogue. Sans `subagentModels`, les modèles routés restent en priorité `5`, les entrées natives GPT utilisent leur -priorité (normalement `9` pour les entrées construites par opencodex), et le groupe routé reste provider/id +priorité (sourcePriority, avec `9` en dernier recours), et le groupe routé reste provider/id alphabétique. ## Exemple diff --git a/docs-site/src/content/docs/fr/guides/sub-agent-surface.md b/docs-site/src/content/docs/fr/guides/sub-agent-surface.md index be3e9d21f82..5941150a65e 100644 --- a/docs-site/src/content/docs/fr/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/fr/guides/sub-agent-surface.md @@ -70,7 +70,7 @@ Par exemple, sans sélecteurs de compte et avec cette liste : | 3 | `deepseek/deepseek-v4-flash` | `5` | Première ligne routée restante dans l'ordre alphabétique | | 4 | `google/gemini-3-pro` | `5` | Ligne routée suivante | | 5 | `moonshot/kimi-k3` | `5` | Ligne routée suivante | -| 6+ | `zai/glm-5.2`, puis les lignes GPT natives | `5`, puis `102` | Hors des cinq | +| 6+ | `zai/glm-5.2`, puis les lignes GPT natives | `5`, puis `102 + sourcePriority` | Hors des cinq | Avec une liste vide, opencodex ne fixe aucune priorité et les cinq suivent les priorités normales du catalogue. diff --git a/docs-site/src/content/docs/guides/model-ordering.md b/docs-site/src/content/docs/guides/model-ordering.md index b3ca4772592..35e2b5a6dc0 100644 --- a/docs-site/src/content/docs/guides/model-ordering.md +++ b/docs-site/src/content/docs/guides/model-ordering.md @@ -30,8 +30,18 @@ Without complete-picker ordering, the relevant no-selector priorities are: | `subagentModels[i]` | `i` (`0` through `4`) | The featured rank map in `src/codex/catalog/sync.ts` | | Other routed models | `5` | Routed entry creation in `src/codex/catalog/sync.ts` | | Non-featured routed models listed in `modelPickerOrder` | `1000 + i` | Display-only picker rank in `src/codex/catalog/sync.ts` | -| Native GPT slugs by default | `9` | Native entry creation in `src/codex/catalog/sync.ts` | -| Unselected native models while a featured list exists | At least `featured.length + 100` | Native catalog merge in `src/codex/catalog/sync.ts` | +| Native GPT slugs without a featured list | `sourcePriority` (fallback `9`) | Shared native source-priority resolver | +| Unselected native models while a featured list exists | `featured.length + 100 + sourcePriority` | Shared native source-priority resolver | + +Native source priority comes from the current discovered descriptor, then the pristine catalog +backup, then the same model's retained source value, then the upstream snapshot. Each candidate +must be an integer from `0` through `1000`; invalid values are skipped and the final fallback is +`9`. Published display priorities are never reused as source priorities. With four featured +models, native source priorities `3`, `8` and missing become `107`, `112` and `113` respectively. +This preserves native source order behind featured and ordinary routed rows, including after +repeated sync or featuring and later unfeaturing a native. The live Codex model list, published +catalog and models cache use the same priorities for the same inputs. Account-selector groups +retain their separate stride policy; complete-picker ordering remains the final override. The management API limits `subagentModels` to five entries with `slice(0, 5)` in `src/server/management/agent-settings-routes.ts`. This matches the Codex `spawn_agent` surface, which @@ -73,7 +83,7 @@ With no eligible account selectors and a non-empty featured list, the resulting 3. Unselected native models, pushed below the featured block during catalog merge. Without `subagentModels`, routed models remain at priority `5`, native GPT entries use their normal -priority (normally `9` for entries built by opencodex), and the routed group remains provider/id +source priority (fallback `9`), and the routed group remains provider/id alphabetical. ## Example @@ -101,7 +111,7 @@ The picker begins as follows: | 5 | `gpt-5.6-terra` | `4` | Fifth selection | | 6 | `anthropic/claude-fable-5` | `5` | First remaining routed id in provider/id alphabetical order | | 7 onward | Remaining routed models | `5` | Provider alphabetically, then model id alphabetically | -| After routed models | Remaining native models | `featured.length + 100` or higher | Unselected natives are moved below the featured block | +| After routed models | Remaining native models | `featured.length + 100 + sourcePriority` | Source order is preserved within the native band | The first five entries are the overrides advertised to `spawn_agent`; the rest continue in the normal picker order. With account selectors, the five-entry limit applies after bare native choices diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index d0d52fee5ba..a7bd6c543eb 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -93,7 +93,7 @@ depend on the catalog: | Catalog | Rows that fill the remaining slots | | --- | --- | -| No Codex account selectors | Other routed `provider/model` rows. They all share priority `5`, so they appear by provider name and then model id, alphabetically. Unfeatured native GPT rows sit at priority `100` plus the number of featured ids or higher, so they come later. | +| No Codex account selectors | Other routed `provider/model` rows. They all share priority `5`, so they appear by provider name and then model id, alphabetically. Unfeatured native GPT rows use `featured.length + 100 + sourcePriority`, preserving native source order behind those routed rows. | | Codex account selectors | Account-qualified native rows (`/`), which directly follow the featured rows. Unfeatured routed rows move to priority `1005` and come after them. | For example, with no account selectors and this roster: @@ -109,7 +109,12 @@ For example, with no account selectors and this roster: | 3 | `deepseek/deepseek-v4-flash` | `5` | First remaining routed row alphabetically | | 4 | `google/gemini-3-pro` | `5` | Next routed row | | 5 | `moonshot/kimi-k3` | `5` | Next routed row | -| 6+ | `zai/glm-5.2`, then native GPT rows | `5`, then `102` | Outside the five | +| 6+ | `zai/glm-5.2`, then native GPT rows | `5`, then `102 + sourcePriority` | Outside the five | + +The native source rank uses current discovery before the pristine backup, exact-model retained +source rank and upstream snapshot, with fallback `9`. Repeated sync and feature/unfeature preserve +that unbanded rank. The live list, published catalog and cache agree for equal inputs. See +[Model ordering](/guides/model-ordering/) for validation and picker-order overrides. With an empty roster, opencodex pins nothing and the five follow the catalog's normal priorities. diff --git a/docs-site/src/content/docs/ja/guides/model-ordering.md b/docs-site/src/content/docs/ja/guides/model-ordering.md index 74febb1e782..a00f1cd0c01 100644 --- a/docs-site/src/content/docs/ja/guides/model-ordering.md +++ b/docs-site/src/content/docs/ja/guides/model-ordering.md @@ -28,7 +28,7 @@ selector がない場合の priority は次のとおりです。 --- | ---: | --- | | `subagentModels[i]` | `i`(`0` から `4`) | `src/codex/catalog/sync.ts` の featured rank map | | その他のルーティングモデル | `5` | `src/codex/catalog/sync.ts` のルーティング項目生成 | -| デフォルトネイティブ GPT スラッグ | `9` | `src/codex/catalog/sync.ts` のネイティブ項目生成 | +| デフォルトネイティブ GPT スラッグ | `sourcePriority` | `src/codex/catalog/sync.ts` のネイティブ項目生成 | | featured リストがあるとき選択されていないネイティブモデル | 最小 `featured.length + 100` | `src/codex/catalog/sync.ts` のネイティブカタログマージ | 管理 API は `src/server/management/agent-settings-routes.ts` の `slice(0, 5)` で `subagentModels` を最大 @@ -69,7 +69,7 @@ Codex の priority ソートでもこの先頭順序は保存されます。 3. カタログマージ過程で featured ブロックの下に押し下げられた選択されていないネイティブモデル `subagentModels` がない場合、ルーティングモデルは priority `5` を維持し、ネイティブ GPT 項目は通常 priority -(opencodex が作った項目は通常 `9`)を使います。ルーティンググループ内部は引き続きプロバイダー/ID +(sourcePriority、情報がなければ `9`)を使います。ルーティンググループ内部は引き続きプロバイダー/ID アルファベット順です。 ## 例 diff --git a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md index af233ba13d0..25a084a0fca 100644 --- a/docs-site/src/content/docs/ja/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ja/guides/sub-agent-surface.md @@ -57,7 +57,7 @@ Codex は `spawn_agent` ツールの説明に最大 5 つのモデルを列挙 | 3 | `deepseek/deepseek-v4-flash` | `5` | アルファベット順で最初の残りのルーティング済み行 | | 4 | `google/gemini-3-pro` | `5` | 次のルーティング済み行 | | 5 | `moonshot/kimi-k3` | `5` | 次のルーティング済み行 | -| 6+ | `zai/glm-5.2`、続いてネイティブ GPT 行 | `5`、続いて `102` | 5 つの枠外 | +| 6+ | `zai/glm-5.2`、続いてネイティブ GPT 行 | `5`、続いて `102 + sourcePriority` | 5 つの枠外 | ロスターが空の場合、opencodex は何も固定せず、5 つの枠はカタログ本来の priority に従います。 diff --git a/docs-site/src/content/docs/ko/guides/model-ordering.md b/docs-site/src/content/docs/ko/guides/model-ordering.md index e921bfdfbcd..5aa24876b6e 100644 --- a/docs-site/src/content/docs/ko/guides/model-ordering.md +++ b/docs-site/src/content/docs/ko/guides/model-ordering.md @@ -28,7 +28,7 @@ selector가 없을 때의 priority는 다음과 같습니다. | --- | ---: | --- | | `subagentModels[i]` | `i` (`0`부터 `4`) | `src/codex/catalog/sync.ts`의 featured rank map | | 그 밖의 라우팅 모델 | `5` | `src/codex/catalog/sync.ts`의 라우팅 항목 생성 | -| 기본 네이티브 GPT slug | `9` | `src/codex/catalog/sync.ts`의 네이티브 항목 생성 | +| 기본 네이티브 GPT slug | `sourcePriority` | `src/codex/catalog/sync.ts`의 네이티브 항목 생성 | | featured 목록이 있을 때 선택되지 않은 네이티브 모델 | 최소 `featured.length + 100` | `src/codex/catalog/sync.ts`의 네이티브 카탈로그 병합 | 관리 API는 `src/server/management/agent-settings-routes.ts`의 `slice(0, 5)`로 `subagentModels`를 최대 @@ -69,7 +69,7 @@ Codex의 priority 정렬에서도 이 선두 순서가 보존됩니다. 3. 카탈로그 병합 과정에서 featured 블록 아래로 밀린 선택되지 않은 네이티브 모델 `subagentModels`가 없으면 라우팅 모델은 priority `5`를 유지하고, 네이티브 GPT 항목은 정상 priority -(opencodex가 만든 항목은 보통 `9`)를 사용합니다. 라우팅 그룹 내부는 계속 프로바이더/id +(sourcePriority, 출처가 없으면 `9`)를 사용합니다. 라우팅 그룹 내부는 계속 프로바이더/id 알파벳순입니다. ## 예시 diff --git a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md index 3b1fb5ab0c5..baece5604ce 100644 --- a/docs-site/src/content/docs/ko/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ko/guides/sub-agent-surface.md @@ -57,7 +57,7 @@ Codex는 `spawn_agent` 도구 설명에 모델을 최대 5개까지 나열하며 | 3 | `deepseek/deepseek-v4-flash` | `5` | 알파벳 순으로 첫 번째인 남은 라우팅 행 | | 4 | `google/gemini-3-pro` | `5` | 다음 라우팅 행 | | 5 | `moonshot/kimi-k3` | `5` | 다음 라우팅 행 | -| 6+ | `zai/glm-5.2`, 그다음 네이티브 GPT 행 | `5`, 그다음 `102` | 5개 밖 | +| 6+ | `zai/glm-5.2`, 그다음 네이티브 GPT 행 | `5`, 그다음 `102 + sourcePriority` | 5개 밖 | 로스터가 비어 있으면 opencodex는 아무것도 고정하지 않고, 5개는 카탈로그의 원래 priority를 따릅니다. diff --git a/docs-site/src/content/docs/ru/guides/model-ordering.md b/docs-site/src/content/docs/ru/guides/model-ordering.md index 5ff04e48920..ac6c2997b8b 100644 --- a/docs-site/src/content/docs/ru/guides/model-ordering.md +++ b/docs-site/src/content/docs/ru/guides/model-ordering.md @@ -31,7 +31,7 @@ selector-групп. | --- | ---: | --- | | `subagentModels[i]` | `i` (от `0` до `4`) | Карта рангов избранных в `src/codex/catalog/sync.ts` | | Остальные маршрутизируемые модели | `5` | Создание маршрутизируемых записей в `src/codex/catalog/sync.ts` | -| Нативные GPT-слаги по умолчанию | `9` | Создание нативных записей в `src/codex/catalog/sync.ts` | +| Нативные GPT-слаги по умолчанию | `sourcePriority` | Создание нативных записей в `src/codex/catalog/sync.ts` | | Невыбранные нативные модели при непустом списке избранных | Не менее `featured.length + 100` | Слияние нативного каталога в `src/codex/catalog/sync.ts` | API управления ограничивает `subagentModels` пятью записями через `slice(0, 5)` в @@ -77,7 +77,7 @@ API управления ограничивает `subagentModels` пятью з 3. Невыбранные нативные модели, сдвинутые ниже блока избранных при слиянии каталога. Без `subagentModels` маршрутизируемые модели остаются с приоритетом `5`, нативные GPT-записи -используют обычный приоритет (для записей, построенных opencodex, обычно `9`), а группа +используют обычный приоритет (sourcePriority, резервное значение `9`), а группа маршрутизируемых моделей сохраняет алфавитный порядок «провайдер/id». ## Пример diff --git a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md index a854342ccaf..5e6cbfa807a 100644 --- a/docs-site/src/content/docs/ru/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/ru/guides/sub-agent-surface.md @@ -70,7 +70,7 @@ Codex перечисляет в описании инструмента `spawn_a | 3 | `deepseek/deepseek-v4-flash` | `5` | Первая оставшаяся маршрутизируемая строка по алфавиту | | 4 | `google/gemini-3-pro` | `5` | Следующая маршрутизируемая строка | | 5 | `moonshot/kimi-k3` | `5` | Следующая маршрутизируемая строка | -| 6+ | `zai/glm-5.2`, затем нативные строки GPT | `5`, затем `102` | За пределами пяти | +| 6+ | `zai/glm-5.2`, затем нативные строки GPT | `5`, затем `102 + sourcePriority` | За пределами пяти | С пустым ростером opencodex ничего не закрепляет, и пять мест определяются обычными приоритетами каталога. diff --git a/docs-site/src/content/docs/tr/guides/model-ordering.md b/docs-site/src/content/docs/tr/guides/model-ordering.md index ea42630154c..2a447b1adb3 100644 --- a/docs-site/src/content/docs/tr/guides/model-ordering.md +++ b/docs-site/src/content/docs/tr/guides/model-ordering.md @@ -34,7 +34,7 @@ Aşağıdaki öncelik tabloları ve örnek, seçicinin tamamını sıralama modu | --- | ---: | --- | | `subagentModels[i]` | `i` (`0` - `4`) | `src/codex/catalog/sync.ts` içindeki öne çıkan sıra haritası | | Diğer yönlendirilen modeller | `5` | `src/codex/catalog/sync.ts` içindeki yönlendirilen girdi oluşturma | -| Varsayılan olarak yerel GPT slug'ları | `9` | `src/codex/catalog/sync.ts` içindeki yerel girdi oluşturma | +| Varsayılan olarak yerel GPT slug'ları | `sourcePriority` | `src/codex/catalog/sync.ts` içindeki yerel girdi oluşturma | | Öne çıkan bir liste varken seçilmeyen yerel modeller | En az `featured.length + 100` | `src/codex/catalog/sync.ts` içindeki yerel katalog birleştirme | Yönetim API'si `src/server/management/agent-settings-routes.ts` içinde `slice(0, @@ -89,8 +89,7 @@ olduğunda ortaya çıkan sıra şöyledir: modeller. `subagentModels` olmadan yönlendirilen modeller `5` önceliğinde kalır, yerel GPT -girdileri normal önceliklerini kullanır (opencodex tarafından oluşturulan -girdiler için normalde `9`) ve yönlendirilen grup sağlayıcı/kimlik olarak +girdileri normal önceliklerini kullanır (sourcePriority, kaynak yoksa `9`) ve yönlendirilen grup sağlayıcı/kimlik olarak alfabetik kalır. ## Örnek diff --git a/docs-site/src/content/docs/tr/guides/sub-agent-surface.md b/docs-site/src/content/docs/tr/guides/sub-agent-surface.md index fcca69f6d4e..472cbd048fa 100644 --- a/docs-site/src/content/docs/tr/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/tr/guides/sub-agent-surface.md @@ -71,7 +71,7 @@ Bir ile dört arasında model öne çıkarırsanız Codex kalan yuvaları öncel | 3 | `deepseek/deepseek-v4-flash` | `5` | Alfabetik olarak kalan ilk yönlendirilmiş satır | | 4 | `google/gemini-3-pro` | `5` | Sonraki yönlendirilmiş satır | | 5 | `moonshot/kimi-k3` | `5` | Sonraki yönlendirilmiş satır | -| 6+ | `zai/glm-5.2`, ardından yerel GPT satırları | `5`, ardından `102` | Beşin dışında | +| 6+ | `zai/glm-5.2`, ardından yerel GPT satırları | `5`, ardından `102 + sourcePriority` | Beşin dışında | Kadro boşsa opencodex hiçbir satırı sabitlemez ve beş yuva kataloğun normal önceliklerine göre belirlenir. diff --git a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md index ffe4ffda879..79ba90b9132 100644 --- a/docs-site/src/content/docs/zh-cn/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-cn/guides/model-ordering.md @@ -26,7 +26,7 @@ priority 为 `i * N + j` 的 selector 行,其中 `j` 是从 0 开始的 select | --- | ---: | --- | | `subagentModels[i]` | `i`(`0` 至 `4`) | `src/codex/catalog/sync.ts` 中的 featured rank map | | 其他路由模型 | `5` | `src/codex/catalog/sync.ts` 中创建路由条目的逻辑 | -| 默认原生 GPT slug | `9` | `src/codex/catalog/sync.ts` 中创建原生条目的逻辑 | +| 默认原生 GPT slug | `sourcePriority` | `src/codex/catalog/sync.ts` 中创建原生条目的逻辑 | | 存在 featured 列表时未选中的原生模型 | 至少为 `featured.length + 100` | `src/codex/catalog/sync.ts` 中合并原生目录的逻辑 | 管理 API 在 `src/server/management/agent-settings-routes.ts` 中使用 `slice(0, 5)`,把 @@ -67,7 +67,7 @@ priority,因此 Codex 的 priority 排序会保留这个开头序列。 3. 在目录合并过程中被移到 featured 区块之后的未选中原生模型。 如果没有 `subagentModels`,路由模型保持 priority `5`,原生 GPT 条目使用正常 priority -(opencodex 创建的条目通常为 `9`),路由组内部仍按 provider/id 字母排序。 +(sourcePriority,缺少来源时回退到 `9`),路由组内部仍按 provider/id 字母排序。 ## 示例 diff --git a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md index 41634687f33..a21b5265200 100644 --- a/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-cn/guides/sub-agent-surface.md @@ -57,7 +57,7 @@ Codex 在 `spawn_agent` 工具描述中最多列出五个模型,并由 Codex | 3 | `deepseek/deepseek-v4-flash` | `5` | 按字母顺序第一个剩余的路由行 | | 4 | `google/gemini-3-pro` | `5` | 下一个路由行 | | 5 | `moonshot/kimi-k3` | `5` | 下一个路由行 | -| 6+ | `zai/glm-5.2`,然后是原生 GPT 行 | `5`,然后是 `102` | 不在前五之内 | +| 6+ | `zai/glm-5.2`,然后是原生 GPT 行 | `5`,然后是 `102 + sourcePriority` | 不在前五之内 | roster 为空时,opencodex 不固定任何行,前五个按目录原有的 priority 决定。 diff --git a/docs-site/src/content/docs/zh-tw/guides/model-ordering.md b/docs-site/src/content/docs/zh-tw/guides/model-ordering.md index d944aef1993..d3767c43190 100644 --- a/docs-site/src/content/docs/zh-tw/guides/model-ordering.md +++ b/docs-site/src/content/docs/zh-tw/guides/model-ordering.md @@ -20,7 +20,7 @@ Codex 的 models-manager 按 `priority` 升序排列選擇器中可見的目錄 | --- | ---: | --- | | `subagentModels[i]` | `i`(`0` 至 `4`) | `src/codex/catalog/sync.ts` 中的 featured rank map | | 其他路由模型 | `5` | `src/codex/catalog/sync.ts` 中建立路由條目的邏輯 | -| 預設原生 GPT slug | `9` | `src/codex/catalog/sync.ts` 中建立原生條目的邏輯 | +| 預設原生 GPT slug | `sourcePriority` | `src/codex/catalog/sync.ts` 中建立原生條目的邏輯 | | 存在 featured 列表時未選中的原生模型 | 至少為 `featured.length + 100` | `src/codex/catalog/sync.ts` 中合併原生目錄的邏輯 | 管理 API 在 `src/server/management/agent-settings-routes.ts` 中使用 `slice(0, 5)`,把 @@ -61,7 +61,7 @@ featured 列表非空時,最終順序為: 3. 在目錄合併過程中被移到 featured 區塊之後的未選中原生模型。 如果沒有 `subagentModels`,路由模型保持 priority `5`,原生 GPT 條目使用正常 priority -(opencodex 建立的條目通常為 `9`),路由組內部仍按 provider/id 字母排序。 +(sourcePriority,缺少來源時回退至 `9`),路由組內部仍按 provider/id 字母排序。 ## 示例 diff --git a/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md b/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md index 6f9d6cdf2f8..0e918f014c3 100644 --- a/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/zh-tw/guides/sub-agent-surface.md @@ -57,7 +57,7 @@ Codex 在 `spawn_agent` 工具說明中最多列出五個模型,並由 Codex | 3 | `deepseek/deepseek-v4-flash` | `5` | 依字母順序第一個剩餘的路由列 | | 4 | `google/gemini-3-pro` | `5` | 下一個路由列 | | 5 | `moonshot/kimi-k3` | `5` | 下一個路由列 | -| 6+ | `zai/glm-5.2`,接著是原生 GPT 列 | `5`,接著是 `102` | 不在前五之內 | +| 6+ | `zai/glm-5.2`,接著是原生 GPT 列 | `5`,接著是 `102 + sourcePriority` | 不在前五之內 | 名冊為空時,opencodex 不固定任何列,前五個依目錄原有的 priority 決定。 diff --git a/scripts/test-layout/layout-codex-collision.json b/scripts/test-layout/layout-codex-collision.json index 632823be628..44e9aecc360 100644 --- a/scripts/test-layout/layout-codex-collision.json +++ b/scripts/test-layout/layout-codex-collision.json @@ -5,6 +5,8 @@ "codex-cli-update-admission.test.ts": "codex-integration", "codex-cli-update-recovery.test.ts": "codex-integration", "codex-cli-update-worker.test.ts": "codex-integration", + "native-source-priority.test.ts": "codex-integration", + "server-native-catalog-priority.test.ts": "server", "codex-catalog-heal-reanchor.test.ts": "codex-integration", "codex-quota-persistence.test.ts": "codex-integration", "codex-sync-user-edits.test.ts": "codex-integration", diff --git a/src/codex/catalog/build-entries.ts b/src/codex/catalog/build-entries.ts index 785b5f6314c..0a27a026986 100644 --- a/src/codex/catalog/build-entries.ts +++ b/src/codex/catalog/build-entries.ts @@ -51,6 +51,7 @@ import { deriveEntry, finishUpstreamNativeEntry, isExactComboCatalogEntry } from import { PICKER_ORDER_PRIORITY_BASE, SPAWN_PRIORITY_FIELD } from "./subagent-roster"; import { applyConfiguredUltraFast, configuredUltraFastSlugs } from "./configured-tiers"; import { applyNativeUltraFastTier } from "./native-ultrafast"; +import { applyNativePriority, retainedNativeSourcePriorities, type NativePrioritySource } from "./native-priority"; export interface ObservedCatalogEntryBuildInput { readonly template: RawEntry | null; @@ -74,6 +75,7 @@ export interface ObservedCatalogEntryBuildInput { readonly accountNativeSlugsBySelector?: ReadonlyMap; /** Codex-only manual selector metadata; deliberately independent of live permission. */ readonly reserve?: ReserveCatalogProjection; + readonly nativeSource?: NativePrioritySource; } /** Build entries with the process-observed Codex feature state. */ @@ -93,6 +95,7 @@ export function buildCatalogEntries( accountNativeSlugsBySelector?: ReadonlyMap, keepNativeChatGptOnV1 = false, modelPickerOrder: readonly string[] = [], + nativeSource?: NativePrioritySource, ): RawEntry[] { const entries = buildCatalogEntriesFromObservedState({ template, @@ -111,6 +114,7 @@ export function buildCatalogEntries( openaiContextCap: contextCap, accountNativeSlugs, accountNativeSlugsBySelector, + nativeSource, }); // Live discovery does not run the disk merge; use the same captured declarations and row fences. const configured = configuredUltraFastSlugs(goModels); @@ -143,6 +147,7 @@ export function buildCatalogEntriesFromObservedState({ accountNativeSlugs, accountNativeSlugsBySelector, reserve, + nativeSource, }: ObservedCatalogEntryBuildInput): RawEntry[] { // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog @@ -209,7 +214,7 @@ export function buildCatalogEntriesFromObservedState({ .map(catalogModelSlug)); for (const slug of gptSlugs.filter(slug => !isCodexControlPlaneModel(slug))) { const native = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap); - if (rank.has(slug)) native.priority = rank.get(slug)!; + applyNativePriority(native, slug, rank, featured?.length ?? 0, nativeSource); nativeEntries.push(native); const nativeAlias = nativeAliasesBySlug.get(slug); if (!nativeAlias || collisionSkipped.has(nativeAlias)) { @@ -239,7 +244,7 @@ export function buildCatalogEntriesFromObservedState({ ?? gptSlugs; const accountNativeEntries = selectorNativeSlugs.filter(slug => slug !== NATIVE_RESERVE_MODEL && !isCodexControlPlaneModel(slug)).map(slug => ( nativeEntriesBySlug.get(slug) - ?? deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap) + ?? applyNativePriority(deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9, undefined, new Set(), openaiContextCap), slug, rank, featured?.length ?? 0, nativeSource) )); if (reserve?.mainSelectors.includes(selector)) accountNativeEntries.push(reserve.source); for (const [nativeIndex, native] of accountNativeEntries.entries()) { @@ -684,12 +689,7 @@ export function mergeCatalogEntriesFromObservedState({ return staleComboKeys.has(key) && !currentNonComboKeys.has(key); }); const catalogModelsForMerge = [...detachedCatalogModels, ...restoredComboShadows]; - const nativePriority = (slug: string, fallback: unknown): number => { - const base = baseline.get(slug) - ?? (typeof fallback === "number" ? fallback : 9); - if (rank.has(slug)) return rank.get(slug)!; - return featured.length > 0 ? Math.max(base, featured.length + 100) : base; - }; + const nativeSource: NativePrioritySource = { baseline, retainedBySlug: retainedNativeSourcePriorities(catalogModelsForMerge) }; const nativeSourceEntries = includeNativeOpenAi ? catalogModelsForMerge .filter(m => typeof m.slug === "string" @@ -707,10 +707,9 @@ export function mergeCatalogEntriesFromObservedState({ if (shouldUpgradeToUpstreamEntry(m)) { const upstream = upstreamNativeEntry(slug)!; const finished = finishUpstreamNativeEntry(upstream, 9, openaiContextCap); - finished.priority = nativePriority(slug, upstream.priority); - return finished; + return applyNativePriority(finished, slug, rank, featured.length, nativeSource); } - const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m[SPAWN_PRIORITY_FIELD] ?? m.priority) }); + const preserved = normalizeServiceTiers(applyNativePriority({ ...m }, slug, rank, featured.length, nativeSource)); // Recompute spawn rank from current featured models, not a prior picker override. delete preserved[SPAWN_PRIORITY_FIELD]; // Older natives kept from disk still need the mock top tiers (max + ultra always @@ -736,12 +735,12 @@ export function mergeCatalogEntriesFromObservedState({ template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", - nativePriority(slug, upstreamNativeEntry(slug)?.priority), + 9, undefined, new Set(), openaiContextCap, ); - entry.priority = nativePriority(slug, upstreamNativeEntry(slug)?.priority); + applyNativePriority(entry, slug, rank, featured.length, nativeSource); native.push(entry); } } diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index c5e9f0e2eb0..b6d859c7e3e 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -35,6 +35,7 @@ import { decodeOverlayState } from "../shim-state-file"; import { activeCodexModelsCachePath, catalogBackupPathFor, findNativeTemplate, findSupportedNativeTemplate, isDefaultCatalogPath, legacyCatalogBackupPath, parseCatalogJson, readCatalog, readCatalogBackup, readCodexCatalogPath } from "./parsing"; import type { RawCatalog, RawEntry } from "./parsing"; +import { isPlainObject } from "../../lib/plain-data"; import { codexExecInvocation, isSpawnableCodexCandidate } from "../exec-invocation"; import { parsePersistedCodexRuntime, @@ -681,11 +682,13 @@ export function readCurrentCodexModelsCache(): RawCatalog | null { export function loadCatalogTemplate(): RawEntry | null { const catalogPath = readCodexCatalogPath(); const bundled = bundledCodexCatalogSnapshot(); + const fromRows = (catalog: RawCatalog | null) => findSupportedNativeTemplate(catalog + ? { ...catalog, models: (catalog.models ?? []).filter(isPlainObject) } : null); // Template inheritance only. The validity gates in this file keep `findNativeTemplate` // so a catalog carrying only a newly launched native row stays valid (#2813). - const native = findSupportedNativeTemplate(readCatalog(catalogPath)) - ?? findSupportedNativeTemplate(readCatalogBackup(catalogPath)) - ?? findSupportedNativeTemplate(readCatalog(activeCodexModelsCachePath())) - ?? findSupportedNativeTemplate(bundled ? JSON.parse(JSON.stringify(bundled)) as RawCatalog : null); + const native = fromRows(readCatalog(catalogPath)) + ?? fromRows(readCatalogBackup(catalogPath)) + ?? fromRows(readCatalog(activeCodexModelsCachePath())) + ?? fromRows(bundled ? JSON.parse(JSON.stringify(bundled)) as RawCatalog : null); return native ? JSON.parse(JSON.stringify(native)) : null; } diff --git a/src/codex/catalog/derive-entry.ts b/src/codex/catalog/derive-entry.ts index 8a710a295a3..fc23d85902e 100644 --- a/src/codex/catalog/derive-entry.ts +++ b/src/codex/catalog/derive-entry.ts @@ -26,6 +26,7 @@ import { isGpt56NativeSlug, } from "./effort"; import { CATALOG_INACTIVE_REASON_FIELD, SPAWN_PRIORITY_FIELD } from "./subagent-roster"; +import { OCX_NATIVE_SOURCE_PRIORITY_FIELD } from "./native-priority"; import { applyRoutedDescription } from "./ownership"; export function finishUpstreamNativeEntry(clone: RawEntry, priority: number, contextCap?: NativeContextLimitsInput): RawEntry { @@ -127,6 +128,7 @@ export function deriveEntry( delete e.opencodex_native_display_name; // A cached template may carry display-order history; each new row owns its natural rank. delete e[SPAWN_PRIORITY_FIELD]; + delete e[OCX_NATIVE_SOURCE_PRIORITY_FIELD]; e.slug = slug; e.display_name = routedDisplayName(slug, model); applyRoutedDescription(e, desc, model); diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 3a5afc515fe..5da4d4dccb3 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -32,6 +32,7 @@ import type { NormalizedComboConfig } from "../../combos/types"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { redactSecretString } from "../../lib/redact"; import { pinnedNativeModelRows } from "./pinned-models"; +import { isPlainObject } from "../../lib/plain-data"; import type { RawEntry } from "./parsing"; @@ -858,11 +859,11 @@ export function observedAccountBoundNativeEntries( */ export function accountBoundNativeOpenAiSlugs( observedEntries: readonly RawEntry[] = [ - ...(readCurrentCodexModelsCache()?.models ?? []), + ...(readCurrentCodexModelsCache()?.models ?? []).filter(isPlainObject), // Existing generated rows are also safe to reuse after a process starts without a cache // invalidation pass; bare user-authored catalog rows are intentionally not trusted here. ...(readCurrentCodexCatalog()?.models ?? []).filter(entry => - trustedAccountBoundNativeCatalogSlug(entry) !== undefined), + isPlainObject(entry) && trustedAccountBoundNativeCatalogSlug(entry) !== undefined), ], ): string[] { const observed = observedEntries.flatMap(entry => { @@ -883,9 +884,9 @@ export function accountBoundNativeOpenAiSlugs( export function accountBoundNativeOpenAiSlugsBySelector( config: AccountSelectorConfig, observedEntries: readonly RawEntry[] = [ - ...(readCurrentCodexModelsCache()?.models ?? []), + ...(readCurrentCodexModelsCache()?.models ?? []).filter(isPlainObject), ...(readCurrentCodexCatalog()?.models ?? []).filter(entry => - trustedAccountBoundNativeCatalogSlug(entry) !== undefined), + isPlainObject(entry) && trustedAccountBoundNativeCatalogSlug(entry) !== undefined), ], ): ReadonlyMap { const selectors = visibleCodexAccountSelectors(config); @@ -926,7 +927,7 @@ export function observedAccountBoundNativeOpenAiSlugs( function catalogNativeSlugs(): string[] { const cat = readCurrentCatalogOrCache(); - const models = cat?.models ?? []; + const models = (cat?.models ?? []).filter(isPlainObject); const live = models.flatMap(entry => { const slug = typeof entry.slug === "string" ? entry.slug : ""; return !slug.includes("/") && SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) ? [slug] : []; diff --git a/src/codex/catalog/native-priority.ts b/src/codex/catalog/native-priority.ts new file mode 100644 index 00000000000..099ce191905 --- /dev/null +++ b/src/codex/catalog/native-priority.ts @@ -0,0 +1,63 @@ +import { discoveredNativeOpenAiModels } from "./native-models"; +import { UPSTREAM_NATIVE_ENTRIES } from "./metadata"; +import { readCatalogBackup, type RawEntry } from "./parsing"; +import { isPlainObject } from "../../lib/plain-data"; + +// Private provenance, like opencodex_spawn_priority: Codex ignores unknown fields. +// This is always the unbanded source rank, independent of picker/guidance history. +export const OCX_NATIVE_SOURCE_PRIORITY_FIELD = "opencodex_native_source_priority"; +export const NATIVE_SOURCE_PRIORITY_MAX = 1_000; + +export interface NativePrioritySource { + readonly baseline?: ReadonlyMap; + readonly retainedBySlug?: ReadonlyMap; +} + +/** Optional native provenance validates its rows without changing shared catalog parsing. */ +export function readNativeBaseline(catalogPath: string): Map { + const baseline = new Map(); + for (const row of readCatalogBackup(catalogPath)?.models ?? []) { + if (isPlainObject(row) && typeof row.slug === "string" && !row.slug.includes("/") + && typeof row.priority === "number" && Number.isFinite(row.priority)) { + baseline.set(row.slug, row.priority); + } + } + return baseline; +} + +function validSourcePriority(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) + && value >= 0 && value <= NATIVE_SOURCE_PRIORITY_MAX; +} + +export function retainedNativeSourcePriorities(rows: readonly unknown[]): Map { + return new Map(rows.flatMap(row => isPlainObject(row) && typeof row.slug === "string" && !row.slug.includes("/") + && validSourcePriority(row[OCX_NATIVE_SOURCE_PRIORITY_FIELD]) + ? [[row.slug, row[OCX_NATIVE_SOURCE_PRIORITY_FIELD]] as const] : [])); +} + +export function resolveNativeSourcePriority(slug: string, source: NativePrioritySource = {}): number { + // This registry is the same pristine source upstreamNativeEntry clones. Reading one scalar + // avoids cloning the full instruction payload a second time for every native row. + const upstream = UPSTREAM_NATIVE_ENTRIES.get(slug)?.priority; + if (discoveredNativeOpenAiModels().includes(slug) && validSourcePriority(upstream)) return upstream; + const baseline = source.baseline?.get(slug); + if (validSourcePriority(baseline)) return baseline; + const retained = source.retainedBySlug?.get(slug); + if (validSourcePriority(retained)) return retained; + return validSourcePriority(upstream) ? upstream : 9; +} + +/** Assign after derivation: finishUpstreamNativeEntry treats priority 9 as keep-upstream. */ +export function applyNativePriority( + entry: RawEntry, + slug: string, + rank: ReadonlyMap, + featuredCount: number, + source?: NativePrioritySource, +): RawEntry { + const priority = resolveNativeSourcePriority(slug, source); + entry[OCX_NATIVE_SOURCE_PRIORITY_FIELD] = priority; + entry.priority = rank.get(slug) ?? (featuredCount > 0 ? featuredCount + 100 + priority : priority); + return entry; +} diff --git a/src/codex/catalog/retained-sync.ts b/src/codex/catalog/retained-sync.ts index 5f959ac3ee9..8cc5a37e758 100644 --- a/src/codex/catalog/retained-sync.ts +++ b/src/codex/catalog/retained-sync.ts @@ -37,10 +37,10 @@ import { readCatalogBackup, readCodexCatalogPath, readCodexCatalogPathForHome, - readNativeBaseline, nativeMultiAgentDefaults, } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; +import { readNativeBaseline } from "./native-priority"; import { ocxRoutedNamespaceCounts, routedRemovalBackedByConfigFile, unconfiguredRoutedRemoval } from "./routed-removal"; import { ConfigMutationLockError } from "../../config/mutation-lock"; import { diff --git a/src/server/codex-model-list.ts b/src/server/codex-model-list.ts new file mode 100644 index 00000000000..8d710b89251 --- /dev/null +++ b/src/server/codex-model-list.ts @@ -0,0 +1,46 @@ +import { websocketsEnabled } from "../config"; +import type { OcxConfig } from "../types"; +import { buildCatalogEntries } from "../codex/catalog/build-entries"; +import { loadCatalogTemplate } from "../codex/catalog/bundled"; +import { applyNativeVisibility, nativeContextLimits } from "../codex/catalog/metadata"; +import { readCatalog, readCodexCatalogPath, type CatalogModel } from "../codex/catalog/parsing"; +import { readNativeBaseline, retainedNativeSourcePriorities } from "../codex/catalog/native-priority"; + +interface CodexModelListInput { + config: OcxConfig; + nativeSlugs: string[]; + availableAccountNativeSlugs: string[]; + accountNativeSlugs: string[]; + accountNativeSlugsBySelector: ReadonlyMap; + goOrdered: CatalogModel[]; + exactComboSlugs: ReadonlySet; + accountSelectors: string[]; + suppressedBareNativeSlugs: ReadonlySet; + disabledModels: ReadonlySet; +} + +/** Read-only Codex-shaped discovery; publication and backup creation belong to sync. */ +export function buildCodexModelList({ + config, nativeSlugs, availableAccountNativeSlugs, accountNativeSlugs, + accountNativeSlugsBySelector, goOrdered, exactComboSlugs, accountSelectors, + suppressedBareNativeSlugs, disabledModels, +}: CodexModelListInput) { + const catalogPath = readCodexCatalogPath(); + const nativeSource = { + baseline: readNativeBaseline(catalogPath), + retainedBySlug: retainedNativeSourcePriorities(readCatalog(catalogPath)?.models ?? []), + }; + const maMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; + // Account rows include hidden supported ids so newly re-enabled natives reappear before sync. + const catalogNativeSlugs = accountSelectors.length > 0 + ? [...new Set([...availableAccountNativeSlugs, ...accountNativeSlugs])] + : nativeSlugs; + const entries = buildCatalogEntries( + loadCatalogTemplate(), catalogNativeSlugs, goOrdered, config.subagentModels, + websocketsEnabled(config), maMode, exactComboSlugs, accountSelectors, + suppressedBareNativeSlugs, new Set(), nativeContextLimits(config), accountNativeSlugs, + accountNativeSlugsBySelector, config.keepNativeChatGptOnV1 === true, config.modelPickerOrder, + nativeSource, + ); + return { models: applyNativeVisibility(entries, disabledModels, accountSelectors.length > 0, new Set(accountNativeSlugs)) }; +} diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 8a89b16b610..52536d6c48d 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -953,7 +953,7 @@ export function createServeOptions(ctx: ServeOptionsContext) { } throw error; } - const { accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, buildCatalogEntries, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeContextLimits, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxOutputTokens, nativeOpenAiContextTier, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); + const { accountBoundNativeOpenAiSlugsBySelector, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, NATIVE_OPENAI_MODELS, nativeContextLimits, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxOutputTokens, nativeOpenAiContextTier, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../../codex/catalog"); const { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } = await import("../../codex/catalog/native-models"); const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); @@ -1102,46 +1102,12 @@ export function createServeOptions(ctx: ServeOptionsContext) { return jsonResponse({ data }, 200, req, policy); } if (url.searchParams.has("client_version")) { - // Codex client → Codex catalog shape: native gpt + namespaced routed models, - // cloned from a native template so required fields (base_instructions, etc.) are present. - // Pass the subagent picks so featured models lead by priority (matches the on-disk file). - // Disabled natives stay in the catalog shape with visibility "hide" (mirrors the - // on-disk sync; codex-rs keeps them out of the picker itself). - const maMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; - // Account rows use the same hidden-inclusive supported set as on-disk sync. This lets a - // newly re-enabled native reappear under each selector before the next sync, while the - // no-selector path keeps nativeOpenAiSlugs()'s existing visibility-sensitive behavior. - const catalogNativeSlugs = accountSelectors.length > 0 - ? [...new Set([ - ...availableAccountNativeSlugs, - ...accountNativeSlugs, - ])] - : nativeSlugs; - const entries = buildCatalogEntries( - loadCatalogTemplate(), - catalogNativeSlugs, - goOrdered, - config.subagentModels, - websocketsEnabled(config), - maMode as "v1" | "default" | "v2", - exactComboSlugs, - accountSelectors, - suppressedBareNativeSlugs, - new Set(), - nativeContextLimits(config), - accountNativeSlugs, - accountNativeSlugsBySelector, - config.keepNativeChatGptOnV1 === true, - config.modelPickerOrder, - ); - return jsonResponse({ - models: applyNativeVisibility( - entries, - disabledModels, - accountSelectors.length > 0, - new Set(accountNativeSlugs), - ), - }, 200, req, policy); + const { buildCodexModelList } = await import("../codex-model-list"); + return jsonResponse(buildCodexModelList({ + config, nativeSlugs, availableAccountNativeSlugs, accountNativeSlugs, + accountNativeSlugsBySelector, goOrdered, exactComboSlugs, accountSelectors, + suppressedBareNativeSlugs, disabledModels, + }), 200, req, policy); } // OpenAI list shape: native gpt bare + routed models namespaced "/" // (pure availability list — disabled natives are omitted entirely). diff --git a/structure/catalog.md b/structure/catalog.md index e0faab25764..7c86ae94023 100644 --- a/structure/catalog.md +++ b/structure/catalog.md @@ -66,7 +66,7 @@ provider-wide fallback. Exact model output limits precede the provider default o compatibility is `null`: Codex's hash-change trigger requires two non-null, unequal hashes, so native/routed switches do not compact merely because of a synthetic marker. Native rows and Codex-forward aliases keep upstream hashes; foreign rows keep valid hashes. Token limits are unchanged; -- forces strict Codex catalog fields required by the current parser; +- forces strict Codex catalog fields required by the current parser. `src/codex/catalog/parsing.ts` preserves model arrays verbatim, including id-only rows used for prompt text and reasoning-summary support, and treats non-array model collections as unavailable. Native provenance in `src/codex/catalog/native-priority.ts`, live native-slug discovery and template selection validate rows locally; baseline priorities must be finite numbers and retained source ranks must be valid. Live discovery tolerates malformed optional provenance without repairing its files. Bundled-catalog validity still rejects non-object rows; retained sync keeps its existing null-row rejection in native recovery before provenance is read; - hides `disabledModels` without blocking direct routing (routed provider ids are excluded; account-qualified native ids hide only that selector row; BARE native slugs hide the bare row and all account-selector clones and drop that model family from raw `/v1/models`); @@ -98,7 +98,7 @@ Custom rows replace discovered rows that encode to the same Codex-facing slug. F On the default `opencodex-catalog.json` path, sync deliberately uses two catalog sources: Codex's bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies the rows being merged. `src/codex/catalog/bundled.ts` treats unsupported bundled-model commands, spawn failures and structurally invalid model rows (null, arrays or other non-objects) as unavailable in both synchronous and asynchronous loading, preserving the existing catalog fallback. This split is required because empty or partial provider discovery must preserve routed entries and genuine user-native rows from the file that will be overwritten; a bundled catalog never contains those rows. Retained sync and evidence-bound convergence -share an explicit observed-state merge policy and restore native priorities from the once-only pristine backup rather than from a catalog whose priorities may already have been rewritten. A configured custom catalog remains the native +share an explicit observed-state merge policy and resolve unbanded priorities through the [native source-priority contract](subagents.md#native-source-priority), including live/published/cache parity. A configured custom catalog remains the native metadata/template authority even when a bundled-catalog memo is warm. Both paths may use an admitted matching bundled memo only as installed-runtime capability evidence to remove unsupported reasoning efforts; convergence never probes Codex itself. The default-on scheduler in `src/codex/catalog-auto-refresh.ts` settles bundled runtime and authenticated Codex roster observations before admission, using dynamic imports, a 15-second source wait and asynchronous selected-runtime/model probes. Request metadata and templates use the same-selection last confirmed bundled memo, refresh its runtime observation every 15 seconds without awaiting it, and fall back to existing catalog/cache sources while a cold or changed selection settles. The bundled evidence keeps its 60-second freshness bound for convergence; failed request-triggered refreshes retain confirmed rows with a 60-second retry cooldown. Shared flights have a 45-second aggregate deadline, cancel obsolete selections and reject late catalog or runtime persistence after observed input/epoch changes. Explicit CLI synchronous loading and compatibility checks remain synchronous. Retained sync settles the async source before reading the content and evidence it later revalidates. Source failure uses existing evidence, while a stopped generation cannot start the next source or converge. A changed set records `reloadRequired` through `src/codex/catalog-refresh-status.ts` for observed running app-servers and logs one content-free restart hint; a no-op keeps it while processes remain stale or the restart observation is unknown and clears it when they are fresh or gone. Automatic refresh never restarts processes. A changed set also runs the client fan-out the server lifecycle registers through `setCatalogAutoRefreshClientFanout`; it refreshes only already-connected clients ([integrations](clients/integrations.md#owned-catalog-convergence)), binds each delivery to the newest unrevoked server owner and stops with the generation or that owner's revocation or release, refuses configuration drift except Desktop applied-marker updates, and logs only a failure count; `tests/codex-integration/catalog-auto-refresh-client-fanout.test.ts` covers it. `tests/codex-integration/catalog-auto-refresh-scheduler.test.ts` covers these boundaries. `src/codex/catalog/bundled.ts` and `src/codex/runtime.ts` prefer the recorded native `launcherPath` from validated schema-2 PATH-overlay state within their shim candidates, retaining legacy candidates and other priorities. `src/codex/features.ts` follows that backing only when the selected wrapper matches validated state; it never probes unrelated installations. Overlay state and activation follow [the runtime lifecycle contract](runtime.md#lifecycle). diff --git a/structure/subagents.md b/structure/subagents.md index 0cda574b70e..3f7113f997d 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -255,6 +255,28 @@ through `ocx agent subagents set` or the opencodex configuration. Retired native excluded by the [shared catalog](catalog.md#shared-catalog); saved user choices are not rewritten by that retirement. Quota fallback retains independent shared/Reserve evidence. +### Native source priority + +`src/codex/catalog/native-priority.ts` owns native priority provenance for the builder and merge. +It accepts only safe integers in `0..1000`, trying the current discovered descriptor, pristine +catalog-backup baseline, the exact slug's retained `opencodex_native_source_priority`, then the +upstream snapshot, finally `9`. Invalid candidates are skipped. Published `priority` and +`opencodex_spawn_priority` never supply source provenance. Every ordinary emitted native row +carries its unbanded source value; `src/codex/catalog/derive-entry.ts` strips it from template clones. +Featured bare rows retain their rank. With a nonempty featured list, unfeatured bare natives use +`featured.length + 100 + sourcePriority`; otherwise they use `sourcePriority`. Account-selector +stride priorities and routed priority `5` (or their existing selector band) keep their own rules; +the source bound does not promise disjointness from account-selector priorities. Complete picker +ordering remains the final display override, independently of private guidance priority. + +`src/server/codex-model-list.ts` resolves the active catalog path once per live Codex-shaped list, +reads its pristine baseline and exact-slug retained fields directly from that file, and supplies +both to the same builder. Missing, null or corrupt catalogs contribute empty inputs. This path +never creates a backup or publishes a catalog. For equal inputs, live `/v1/models?client_version`, +the published catalog and its `models_cache.json` copy carry equal priorities. Concurrent reads may +observe the complete previous or next publication. Coverage: `tests/codex-integration/native-source-priority.test.ts` +and `tests/server/server-native-catalog-priority.test.ts`. + When account selectors are active, one featured bare native id expands into a complete selector row group. Catalog priorities use the selector count as a stride so each group stays together without widening Codex's five-row advertisement window. Fresh defaults are Astra, GPT-6.1 Sol and Luna @@ -465,7 +487,7 @@ The Models page saves routed snapshots in `modelPickerOrder` and records their o `modelPickerOrderMode` (`alphabetical`, `provider`, `most-used`). Mode is UI provenance, not a catalog sorting policy: catalog writers consume the saved array. Routed-only featured/native bands and complete-picker natural-rank preservation remain as described above. Public -`buildCatalogEntries` accepts the order as its final argument and applies the complete-order +`buildCatalogEntries` accepts the order before optional native-source inputs and applies the complete-order pass after building. On-disk convergence retains its existing post-merge final pass. Claude ModelInfo ordering receives optional `{ modelPickerOrder, featured }` after `fastRows`. diff --git a/tests/codex-integration/catalog-full-picker-order.test.ts b/tests/codex-integration/catalog-full-picker-order.test.ts index 4dd35f915b2..4ea92da209f 100644 --- a/tests/codex-integration/catalog-full-picker-order.test.ts +++ b/tests/codex-integration/catalog-full-picker-order.test.ts @@ -63,7 +63,7 @@ test("sync refreshes native spawn rank when featured models change", () => { const demoted = mergeCatalogEntriesForSync(promoted, [], baseline, ["opencode-go/glm-5.3"], false); applyFullModelPickerOrder(demoted, order); - expect(demoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(101); + expect(demoted.find(entry => entry.slug === sol.slug)?.[SPAWN_PRIORITY_FIELD]).toBe(110); }); test("fresh routed rows do not inherit a previously ordered native template's guidance rank", () => { diff --git a/tests/codex-integration/codex-catalog-golden.test.ts b/tests/codex-integration/codex-catalog-golden.test.ts index 82f2f8db8a6..fa2adb49f1e 100644 --- a/tests/codex-integration/codex-catalog-golden.test.ts +++ b/tests/codex-integration/codex-catalog-golden.test.ts @@ -75,7 +75,7 @@ describe("codex-catalog golden (pure buildCatalogEntries oracle)", () => { expect(projection.map(p => `${p.slug}@${p.priority}`).sort()).toEqual([ "codex-auto-review@43", "gpt-5.5@0", - "gpt-5.6-sol@4", + "gpt-5.6-sol@106", "kiro/claude-opus-4.6@1", "opencode-go/glm-5.2@5", ]); diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 583be7eccc0..5222c0364cb 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -4232,8 +4232,8 @@ describe("Codex catalog routed normalization", () => { { effort: "low", description: "l" }, { effort: "max", description: "m" }, { effort: "ultra", description: "u" }, ], }; - // Genuine: real display name — must be preserved untouched (installed codex is SoT once - // it catches up), marker field proves no replacement happened. + // Genuine metadata is preserved; priority is resolved separately from source provenance. + // The marker proves no metadata replacement happened. const genuineSol = { ...nativeTemplate(), slug: "gpt-5.6-sol", @@ -4251,7 +4251,7 @@ describe("Codex catalog routed normalization", () => { .toEqual(["low", "medium", "high", "xhigh", "max"]); expect(luna?.priority).toBe(8); // upstream priority restored for the upgraded entry expect(sol?.genuine_marker).toBe("from-installed-catalog"); - expect(sol?.priority).toBe(1); + expect(sol?.priority).toBe(4); // no baseline/source field: use snapshot, never published rank }); test("fallback-quality upgrades restore snapshot metadata without losing pristine priority", () => { diff --git a/tests/codex-integration/native-source-priority.test.ts b/tests/codex-integration/native-source-priority.test.ts new file mode 100644 index 00000000000..aabdf973986 --- /dev/null +++ b/tests/codex-integration/native-source-priority.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { buildCatalogEntriesFromObservedState, mergeCatalogEntriesFromObservedState } from "../../src/codex/catalog/build-entries"; +import { deriveEntry } from "../../src/codex/catalog/derive-entry"; +import { setConfiguredNativeOpenAiModels, setDiscoveredNativeOpenAiModels, resetConfiguredNativeOpenAiModelsForTests } from "../../src/codex/catalog/native-models"; +import { catalogBackupPathFor, parseCatalogJson, type RawEntry } from "../../src/codex/catalog/parsing"; +import { readNativeBaseline, retainedNativeSourcePriorities } from "../../src/codex/catalog/native-priority"; +import { stripUnsupportedReasoningSummaryDelivery } from "../../src/adapters/openai-responses/reasoning"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// Literal wire key lets these behavioral regressions run against the pre-fix tree too. +const SOURCE = "opencodex_native_source_priority"; +const ids = ["gpt-9-priority-a", "gpt-9-priority-b", "gpt-9-priority-c"]; +const featured = ["fixture/f1", "fixture/f2", "fixture/f3", "fixture/f4"]; +const routed = [...featured.map(slug => ({ provider: "fixture", id: slug.split("/")[1]! })), { provider: "fixture", id: "filler" }]; +let home: string; +let previousHome: string | undefined; +const row = (slug: string, priority?: unknown): RawEntry => ({ + slug, priority, display_name: `Native ${slug}`, supported_in_api: true, visibility: "list", + supported_reasoning_levels: [{ effort: "high", description: "High" }], +}); +const get = (rows: RawEntry[], slug: string) => rows.find(row => row.slug === slug)!; +const priorities = (rows: RawEntry[]) => ids.map(id => get(rows, id).priority); +function build(picks = featured, selectors: string[] = [], template: RawEntry | null = null) { + return buildCatalogEntriesFromObservedState({ template, gptSlugs: ids, goModels: routed, + featured: picks, wsEnabled: false, multiAgentMode: "default", exactComboSlugs: new Set(), + accountSelectors: selectors, suppressedBareNativeSlugs: new Set(), disabledNativeAccountSlugs: new Set(), + multiAgentV2Enabled: false }); +} +function merge(rows: RawEntry[], baseline = new Map(), picks = featured) { + return mergeCatalogEntriesFromObservedState({ catalogModels: rows, baselineCatalogModels: [], + routedEntries: build(picks).filter(row => String(row.slug).includes("/")), baseline, featured: picks, + wsEnabled: false, template: null, disabledModels: new Set(), selectedModelsByProvider: new Map(), + gatheredProviderNames: new Set(["fixture"]), degradedProviderNames: new Set(), legacyCustomModelSlugs: new Set(), + multiAgentMode: "default", multiAgentV2Enabled: false, exactComboSlugs: new Set(), + hasPhysicalComboProvider: false, includeNativeOpenAi: true, accountBoundEntries: [], + policy: { nativeBackfillSlugs: ids, unsupportedNativeEntries: "preserve", warningPolicy: "suppress" } }); +} +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-native-priority-")); + process.env.OPENCODEX_HOME = home; + setDiscoveredNativeOpenAiModels(ids.map((id, i) => row(id, [3, 8, undefined][i]))); +}); +afterEach(() => { + setDiscoveredNativeOpenAiModels([]); + resetConfiguredNativeOpenAiModelsForTests(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); +}); + +test("four featured precede distinct discovered native source priorities including fallback", () => { + const rows = merge(ids.map(id => row(id))); + expect(priorities(rows)).toEqual([107, 112, 113]); + expect(featured.map(id => get(rows, id).priority)).toEqual([0, 1, 2, 3]); + expect(ids.map(id => get(rows, id)[SOURCE])).toEqual([3, 8, 9]); + expect(priorities(build())).toEqual([107, 112, 113]); +}); +test("current descriptor supersedes a stale pristine baseline", () => { + expect(priorities(merge(ids.map(id => row(id)), new Map(ids.map(id => [id, 1]))))).toEqual([107, 112, 105]); +}); +test("repeated publication never compounds the native band", () => { + const first = merge(ids.map(id => row(id))); + expect(priorities(merge(merge(first)))).toEqual([107, 112, 113]); +}); +test("an upstream priority swap reorders the next publication", () => { + const first = merge(ids.map(id => row(id))); + setDiscoveredNativeOpenAiModels(ids.map((id, i) => row(id, [8, 3, undefined][i]))); + expect(priorities(merge(first))).toEqual([112, 107, 113]); +}); +test("expired descriptors without baseline use only exact-slug retained provenance", () => { + const first = merge(ids.map(id => row(id))); + setDiscoveredNativeOpenAiModels([]); + setConfiguredNativeOpenAiModels(ids); + expect(priorities(merge(merge(first)))).toEqual([107, 112, 113]); +}); +test("featuring then unfeaturing restores source order", () => { + const first = merge(ids.map(id => row(id)), new Map(), [ids[0]!, ...featured]); + expect(get(first, ids[0]!).priority).toBe(0); + expect(get(first, ids[0]!)[SOURCE]).toBe(3); + expect(priorities(merge(first))).toEqual([107, 112, 113]); + expect(priorities(merge(first, new Map(), []))).toEqual([3, 8, 9]); +}); +for (const invalid of [-200, 3.5, NaN, "3", 1e9]) { + test(`invalid source priority ${String(invalid)} falls back without preceding featured`, () => { + setDiscoveredNativeOpenAiModels(ids.map(id => row(id, invalid))); + expect(priorities(merge(ids.map(id => row(id, invalid))))).toEqual([113, 113, 113]); + expect(priorities(build())).toEqual([113, 113, 113]); + }); +} +test("invalid candidates skip to the next valid provenance and ignore legacy published rank", () => { + setDiscoveredNativeOpenAiModels(ids.map(id => row(id, -200))); + const rows = ids.map(id => ({ ...row(id, 0), [SOURCE]: 6, opencodex_spawn_priority: 0 })); + expect(priorities(merge(rows, new Map(ids.map(id => [id, 3.5]))))).toEqual([110, 110, 110]); + setDiscoveredNativeOpenAiModels([]); + expect(priorities(merge(ids.map(id => row(id, 0))))).toEqual([113, 113, 113]); +}); +test("unfeatured routed filler stays at five ahead of the native band", () => { + const rows = merge(ids.map(id => row(id))); + expect(get(rows, "fixture/filler").priority).toBe(5); + expect(priorities(rows)).toEqual([107, 112, 113]); +}); +for (const selectors of [[], ["account"], ["account", "second"]]) { + test(`source ceiling 1000 preserves existing ${selectors.length}-selector stride policy`, () => { + setDiscoveredNativeOpenAiModels(ids.map(id => row(id, 1000))); + const rows = build(featured, selectors); + expect(priorities(rows)).toEqual([1104, 1104, 1104]); + expect(featured.map(id => get(rows, id).priority)).toEqual(selectors.length === 2 ? [0, 2, 4, 6] : [0, 1, 2, 3]); + for (const [index, selector] of selectors.entries()) { + expect(ids.map(id => get(rows, `${selector}/${id}`).priority)).toEqual(ids.map((_, n) => (4 + n) * selectors.length + index)); + } + const nativeFeatured = build([ids[0]!, ...featured.slice(0, 3)], selectors); + expect(get(nativeFeatured, ids[0]!).priority).toBe(0); + expect(featured.slice(0, 3).map(id => get(nativeFeatured, id).priority)).toEqual(selectors.length === 2 ? [2, 4, 6] : [1, 2, 3]); + for (const [index, selector] of selectors.entries()) { + expect(get(nativeFeatured, `${selector}/${ids[0]}`).priority).toBe(index); + } + }); +} +test("a template cloned into another native or routed identity loses source provenance", () => { + const template = { ...row("gpt-9-template", 107), [SOURCE]: 3 }; + expect(deriveEntry(template, "fixture/routed", "Routed", 5, { provider: "fixture", id: "routed" })[SOURCE]).toBeUndefined(); + setDiscoveredNativeOpenAiModels([]); + expect(deriveEntry(template, "gpt-9-unknown", "Native", 9)[SOURCE]).toBeUndefined(); +}); + +test("unfeatured baseline nine is assigned after upstream derivation and overrides retained three", () => { + const rows = buildCatalogEntriesFromObservedState({ template: null, gptSlugs: ["gpt-5.6-sol"], goModels: [], + featured: [], wsEnabled: false, multiAgentMode: "default", exactComboSlugs: new Set(), + accountSelectors: [], suppressedBareNativeSlugs: new Set(), disabledNativeAccountSlugs: new Set(), + multiAgentV2Enabled: false, nativeSource: { baseline: new Map([["gpt-5.6-sol", 9]]), retainedBySlug: new Map([["gpt-5.6-sol", 3]]) } }); + expect(get(rows, "gpt-5.6-sol").priority).toBe(9); + expect(get(rows, "gpt-5.6-sol")[SOURCE]).toBe(9); +}); + +test("baseline skips malformed backup rows and nonnumeric priorities without writing", () => { + const catalogPath = join(home, "catalog.json"); + const backupPath = catalogBackupPathFor(catalogPath); + const backup = JSON.stringify({ models: [null, 3, [], { slug: 5, priority: 1 }, + { slug: ids[0], priority: "3" }, { slug: ids[1], priority: 8 }, { slug: "fixture/routed", priority: 2 }] }); + writeFileSync(backupPath, backup, "utf8"); + expect([...readNativeBaseline(catalogPath)]).toEqual([[ids[1], 8]]); + expect(readFileSync(backupPath, "utf8")).toBe(backup); +}); + +for (const models of [[null, 3, [], { slug: 5 }], {}, null, "unavailable"]) { + test(`wholly unusable backup yields empty baseline ${JSON.stringify(models)}`, () => { + const catalogPath = join(home, "catalog.json"); + const backup = JSON.stringify({ models }); + writeFileSync(catalogBackupPathFor(catalogPath), backup, "utf8"); + expect([...readNativeBaseline(catalogPath)]).toEqual([]); + expect(readFileSync(catalogBackupPathFor(catalogPath), "utf8")).toBe(backup); + }); +} + +test("catalog parsing preserves all rows including id-only rows and malformed elements", () => { + const native = row(ids[0]!, 3); + const idOnly = { id: "gpt-id-only", base_instructions: "Configured instructions.", supports_reasoning_summaries: false }; + const objects = [{}, { slug: 5 }, idOnly, native]; + const models = [null, 3, "bad", [], ...objects]; + expect(parseCatalogJson(JSON.stringify({ version: 7, models }))).toEqual({ version: 7, models }); +}); + +test("an id-only catalog opt-out strips unsupported reasoning summary delivery", () => { + const previousCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = home; + try { + writeFileSync(join(home, "opencodex-catalog.json"), JSON.stringify({ models: [ + { id: "gpt-id-only", supports_reasoning_summaries: false }] }), "utf8"); + const body = { stream_options: { include_usage: true, reasoning_summary_delivery: "sequential_cutoff" } }; + expect(stripUnsupportedReasoningSummaryDelivery(body, "gpt-id-only")) + .toEqual({ stream_options: { include_usage: true } }); + expect(body.stream_options.reasoning_summary_delivery).toBe("sequential_cutoff"); + } finally { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } +}); + +test("retained provenance skips malformed rows and invalid source priorities", () => { + const rows = [null, 3, [], { slug: 5 }, { slug: ids[0], [SOURCE]: 3 }, + ...["8", -1, 1.5, 1001, null].map(value => ({ slug: ids[1], [SOURCE]: value })), + { slug: "fixture/routed", [SOURCE]: 2 }]; + expect([...retainedNativeSourcePriorities(rows)]).toEqual([[ids[0], 3]]); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f67acf795c6..544409315ad 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,4 +1,6 @@ { "service-desktop-recovery.test.ts": "service", "provider-input-modalities.test.ts": "providers", "turn-membership.test.ts": "responses", "turn-membership-ingress.test.ts": "responses", "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", + "native-source-priority.test.ts": "codex-integration", + "server-native-catalog-priority.test.ts": "server", "claude-intercept-acl-barrier.test.ts": "claude-integration", "codex-catalog-heal-reanchor.test.ts": "codex-integration", "tool-identity-hash.test.ts": "adapters", diff --git a/tests/server/server-native-catalog-priority.test.ts b/tests/server/server-native-catalog-priority.test.ts new file mode 100644 index 00000000000..5f474fe7279 --- /dev/null +++ b/tests/server/server-native-catalog-priority.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { SUBAGENT_MODELS_VERSION } from "../../src/config/subagent-models"; +import { startServer } from "../../src/server"; +import { invalidateCodexModelsCache, syncCatalogModels, upstreamNativeEntry } from "../../src/codex/catalog"; +import { catalogBackupPathFor, type RawEntry } from "../../src/codex/catalog/parsing"; +import { resetDiscoveredNativeModelsForTests } from "../../src/codex/catalog/discovered-natives"; +import { resetCatalogRuntimeStateForTests } from "../../src/codex/catalog/build-entries"; +import { resetConfiguredNativeOpenAiModelsForTests } from "../../src/codex/catalog/native-models"; +import { CODEX_FORWARD_BASE_URL } from "../../src/providers/openai-tiers"; +import { createTempHome, type TempHome } from "../helpers/temp-home"; +import { removeTreeAfterBackgroundWork } from "../helpers/remove-tree"; +import { SERVER_BUDGET_MS } from "../helpers/test-budget"; +import type { OcxConfig } from "../../src/types"; + +setDefaultTimeout(SERVER_BUDGET_MS); +const SOURCE = "opencodex_native_source_priority"; +let home: TempHome; +let catalogPath: string; +beforeEach(() => { + home = createTempHome("ocx-live-priority-"); + mkdirSync(home.codexHome); + catalogPath = join(home.codexHome, "priority-catalog.json"); + writeFileSync(join(home.codexHome, "config.toml"), 'model_catalog_json = "priority-catalog.json"\n', "utf8"); + resetCatalogRuntimeStateForTests(); +}); +afterEach(async () => { + resetDiscoveredNativeModelsForTests(); + resetConfiguredNativeOpenAiModelsForTests(); + resetCatalogRuntimeStateForTests(); + await removeTreeAfterBackgroundWork(home.root); + home.remove(); +}); +const readRows = (path: string): RawEntry[] => JSON.parse(readFileSync(path, "utf8")).models; +const config = (slug: string, featured = ["fixture/featured"]): OcxConfig => ({ + port: 0, hostname: "127.0.0.1", defaultProvider: "fixture", subagentModels: featured, + subagentModelsVersion: SUBAGENT_MODELS_VERSION, catalogAutoRefresh: { enabled: false }, + providers: { + openai: { adapter: "openai-responses", baseUrl: CODEX_FORWARD_BASE_URL, authMode: "forward", models: [slug] }, + fixture: { adapter: "openai-chat", baseUrl: "https://priority-fixture.invalid/v1", liveModels: false, models: ["featured"] }, + }, +}); +async function checkSurfaces(next: OcxConfig, slug: string, priority: number, source: number) { + saveConfig(next); + const synced = await syncCatalogModels(next, { allowWhenDesiredDisabled: true }); + expect(synced.refreshOutcome).toBe("committed"); + invalidateCodexModelsCache({ allowWhenDesiredDisabled: true }); + const server = startServer(0); + try { + const before = readFileSync(catalogPath, "utf8"); + const cacheBefore = readFileSync(join(home.codexHome, "models_cache.json"), "utf8"); + const response = await fetch(new URL("/v1/models?client_version=0.160.0", server.url)); + expect(response.status).toBe(200); + const live = (await response.json() as { models: RawEntry[] }).models; + const published = readRows(catalogPath); + const cached = readRows(join(home.codexHome, "models_cache.json")); + expect([live, published, cached].map(rows => rows.find(row => row.slug === slug)?.priority)).toEqual([priority, priority, priority]); + expect([live, published, cached].map(rows => rows.find(row => row.slug === slug)?.[SOURCE])).toEqual([source, source, source]); + expect(readFileSync(catalogPath, "utf8")).toBe(before); + expect(readFileSync(join(home.codexHome, "models_cache.json"), "utf8")).toBe(cacheBefore); + } finally { await server.stop(true); } +} +test("live HTTP, published catalog and cache honor pristine 9 over static snapshot 4", async () => { + const slug = "gpt-5.6-sol"; + const native = upstreamNativeEntry(slug)!; + expect(native.priority).toBe(4); + writeFileSync(catalogPath, JSON.stringify({ models: [native] }), "utf8"); + writeFileSync(catalogBackupPathFor(catalogPath), JSON.stringify({ models: [{ ...native, priority: 9 }] }), "utf8"); + await checkSurfaces(config(slug), slug, 110, 9); +}); +test("without descriptor or baseline exact-slug source beats snapshot across live, repeated sync and feature/unfeature", async () => { + const slug = "gpt-5.6-sol"; + const native = upstreamNativeEntry(slug)!; + expect(native.priority).toBe(4); + writeFileSync(catalogPath, JSON.stringify({ models: [ + { ...native, priority: 104, [SOURCE]: 3 }, + { slug: "fixture/featured", description: "Routed via fixture", priority: 0, visibility: "list" }, + ] }), "utf8"); + // An existing malformed backup is unavailable, never a reason for an HTTP writer to repair it. + writeFileSync(catalogBackupPathFor(catalogPath), "{corrupt", "utf8"); + await checkSurfaces(config(slug), slug, 104, 3); + await checkSurfaces(config(slug), slug, 104, 3); + await checkSurfaces(config(slug, [slug]), slug, 0, 3); + await checkSurfaces(config(slug), slug, 104, 3); + expect(readFileSync(catalogBackupPathFor(catalogPath), "utf8")).toBe("{corrupt"); +}); + +for (const models of [[null, 3, { slug: 5 }], { slug: "gpt-5.6-sol", priority: 1 }]) { + test(`live HTTP ignores structurally malformed optional backup ${JSON.stringify(models)}`, async () => { + const slug = "gpt-5.6-sol"; + const next = config(slug); + saveConfig(next); + writeFileSync(catalogPath, JSON.stringify({ models: [upstreamNativeEntry(slug)] }), "utf8"); + const server = startServer(0); + try { + const url = new URL("/v1/models?client_version=0.160.0", server.url); + const withoutBackup = await fetch(url); + expect(withoutBackup.status).toBe(200); + const expected = await withoutBackup.json(); + const activeBefore = readFileSync(catalogPath, "utf8"); + const backup = JSON.stringify({ models }); + writeFileSync(catalogBackupPathFor(catalogPath), backup, "utf8"); + const response = await fetch(url); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(expected); + expect(readFileSync(catalogPath, "utf8")).toBe(activeBefore); + expect(readFileSync(catalogBackupPathFor(catalogPath), "utf8")).toBe(backup); + } finally { await server.stop(true); } + }); +} + +test("live HTTP skips malformed active native rows while retaining valid exact-slug provenance", async () => { + const slug = "gpt-5.6-sol"; + saveConfig(config(slug)); + const native = { ...upstreamNativeEntry(slug)!, priority: 104, [SOURCE]: 3 }; + writeFileSync(catalogPath, JSON.stringify({ models: [native] }), "utf8"); + const server = startServer(0); + try { + const url = new URL("/v1/models?client_version=0.160.0", server.url); + const clean = await fetch(url); + expect(clean.status).toBe(200); + const expected = await clean.json() as { models: RawEntry[] }; + expect(expected.models.find(row => row.slug === slug)?.[SOURCE]).toBe(3); + const malformed = JSON.stringify({ models: [null, 3, [], { slug: 5 }, native] }); + writeFileSync(catalogPath, malformed, "utf8"); + const response = await fetch(url); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(expected); + expect(readFileSync(catalogPath, "utf8")).toBe(malformed); + } finally { await server.stop(true); } +}); + +test("retained sync ignores malformed backup rows and preserves the backup bytes", async () => { + const slug = "gpt-5.6-sol"; + const native = upstreamNativeEntry(slug)!; + const next = config(slug); + saveConfig(next); + writeFileSync(catalogPath, JSON.stringify({ models: [native] }), "utf8"); + const backup = JSON.stringify({ models: [3, [], { slug: 5 }, { ...native, priority: 9 }] }); + writeFileSync(catalogBackupPathFor(catalogPath), backup, "utf8"); + const result = await syncCatalogModels(next, { allowWhenDesiredDisabled: true }); + expect(result.refreshOutcome).toBe("committed"); + expect(readRows(catalogPath).find(row => row.slug === slug)?.priority).toBe(110); + expect(readFileSync(catalogBackupPathFor(catalogPath), "utf8")).toBe(backup); +}); + +test("retained sync preserves pre-existing rejection of null recovery rows outside provenance", async () => { + const next = config("gpt-5.6-sol"); + saveConfig(next); + const active = JSON.stringify({ models: [upstreamNativeEntry("gpt-5.6-sol")] }); + const backup = JSON.stringify({ models: [null] }); + writeFileSync(catalogPath, active, "utf8"); + writeFileSync(catalogBackupPathFor(catalogPath), backup, "utf8"); + try { + await syncCatalogModels(next, { allowWhenDesiredDisabled: true }); + throw new Error("Expected the existing native recovery rejection"); + } catch (error) { + expect(error).toBeInstanceOf(TypeError); + expect((error as Error).stack).toContain("mergeCatalogModelsWithNativeRecovery"); + } + expect(readFileSync(catalogPath, "utf8")).toBe(active); + expect(readFileSync(catalogBackupPathFor(catalogPath), "utf8")).toBe(backup); +});