From a4878de3837cd55524649a11924c47e8ede801bd Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Fri, 7 Aug 2026 06:51:54 -0400 Subject: [PATCH 1/3] feat(codex): converge account picker catalogs --- .../content/docs/guides/codex-app-models.md | 16 +- .../docs/ja/guides/codex-app-models.md | 12 +- .../ja/reference/configuration/providers.md | 3 +- .../docs/ko/guides/codex-app-models.md | 18 +- .../ko/reference/configuration/providers.md | 3 +- .../docs/reference/configuration/providers.md | 3 +- .../docs/ru/guides/codex-app-models.md | 19 +- .../ru/reference/configuration/providers.md | 3 +- .../docs/zh-cn/guides/codex-app-models.md | 12 +- .../reference/configuration/providers.md | 3 +- src/codex/catalog.ts | 3 +- src/codex/catalog/account-models.ts | 13 +- src/codex/catalog/aggregation.ts | 15 +- src/codex/catalog/bundled.ts | 68 +- src/codex/catalog/effort.ts | 58 +- src/codex/catalog/parsing.ts | 5 + src/codex/catalog/provider-fetch.ts | 97 +- src/codex/catalog/sync.ts | 510 ++++++++--- src/codex/convergence.ts | 196 ++++- src/codex/features.ts | 5 + src/providers/slug-codec.ts | 19 +- structure/03_catalog-and-subagents.md | 9 +- tests/catalog-oauth-observation.test.ts | 30 +- tests/codex-catalog-sync-hardening.test.ts | 70 +- tests/codex-catalog.test.ts | 573 +++++++++++- ...odex-convergence-account-selectors.test.ts | 827 ++++++++++++++++++ tests/codex-convergence-contract.test.ts | 17 + tests/codex-filesystem-evidence.test.ts | 2 +- tests/codex-runtime.test.ts | 62 +- tests/codex-v2-gate.test.ts | 95 +- ...gather-routed-models-single-flight.test.ts | 14 +- tests/native-model-toggle.test.ts | 22 + tests/slug-codec.test.ts | 18 + 33 files changed, 2526 insertions(+), 294 deletions(-) create mode 100644 tests/codex-convergence-account-selectors.test.ts diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index afece18f0..3674e790a 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -9,13 +9,21 @@ App's model picker as normal Codex catalog entries. OpenAI entries use two credential routes: native Codex login and the namespaced `openai-apikey/` API-key transport. Changing `codexAccountMode` between Pool and Direct by -itself does not change picker ids. When `codexAccountNamespaces` has eligible selectors whose +itself does not change picker ids. When account-qualified picker rows are enabled by +`codexAccountPickerEnabled` and `codexAccountNamespaces` has eligible selectors whose mapped accounts still exist, however, opencodex adds separate `/` rows for the mapped accounts and hides the bare native rows from the Codex picker. Selector labels are user-chosen public names with no built-in account-role meaning. Selecting a qualified row uses only its mapped account, does not change the active Pool account, and fails closed instead of switching accounts when the target is unavailable. See [Exact Codex account selectors](/reference/configuration/routing/#exact-codex-account-selectors). + +When the `codexAccountNamespaces` map is empty, account-qualified picker rows are off. If +`codexAccountPickerEnabled` is omitted with a non-empty map, they are treated as enabled for +backward compatibility. Set it to `false` to hide generated qualified rows and restore bare native +rows in the picker without deleting mappings or disabling exact +`/` routing. + API GPT-5.6 entries use 1,050,000 context / 922,000 max input, and `*-pro` picker ids resolve to the base wire model with `reasoning.mode: "pro"` while logs, usage, and picker state keep the virtual id. @@ -72,8 +80,8 @@ metadata instead of an older-template approximation. | Route | Picker ids and catalog metadata | | --- | --- | -| Codex login (no eligible account selectors) | Bare native ids such as `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`; Pool or Direct is selected through `codexAccountMode`. GPT-5.6 rows use a 372,000-token catalog window. | -| Codex login (eligible account selectors) | One `/` row per eligible selector and supported native model; each row uses only its mapped account, and bare native rows are hidden from the picker. Native metadata and context windows are preserved. | +| Codex login (account-qualified rows disabled) | Bare native ids such as `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`; Pool or Direct is selected through `codexAccountMode`. GPT-5.6 rows use a 372,000-token catalog window. | +| Codex login (account-qualified rows enabled with eligible selectors) | One `/` row per eligible selector and supported native model; each row uses only its mapped account, and bare native rows are hidden from the picker. Native metadata and context windows are preserved. | | OpenAI (API key) | Exactly eight namespaced rows: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, and the three `*-pro` virtual ids (1,050,000 context; 922,000 max input for all eight) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (1,050,000) | | Cursor | Static fallback includes `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, and `cursor/gpt-5.6-luna` (1,000,000), plus `cursor/grok-4.5` and `cursor/grok-4.5-fast` (500,000); live account discovery decides which remain visible. | @@ -147,7 +155,7 @@ Codex sorts picker-visible catalog entries by ascending `priority` and advertise native ids or routed `provider/model` ids. Manually configured `subagentModels` also accepts account-qualified `/` ids, but the dashboard does not offer those exact ids; saving the page replaces the list with dashboard-visible choices. opencodex assigns low -catalog priorities in the selected order; when account selectors are active, bare native selections +catalog priorities in the selected order; when account-qualified picker rows are enabled, bare native selections expand into selector-qualified groups. Other models remain callable by exact id. The featured-model list is separate from the Dashboard's **Sub-agent delegation** selection. It diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md index 8261d274e..7e5e3df87 100644 --- a/docs-site/src/content/docs/ja/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md @@ -5,7 +5,11 @@ description: opencodex モデルが、共有 Codex カタログを通じて Code opencodex は Codex アプリにパッチを適用しません。 Codex CLI/TUI が既に使用しているのと同じ Codex 設定とモデル カタログを書き込みます。 Codex アプリはその共有状態を読み取るため、ルーティングされたモデルは通常の Codex カタログ エントリとしてアプリのモデル ピッカーに表示されます。 -OpenAI エントリには、ネイティブ Codex ログインと、名前空間付きの `openai-apikey/` API キーという 2 つの資格情報ルートがあります。`codexAccountMode` だけを Pool と Direct の間で変更しても、ピッカー ID は変わりません。ただし、`codexAccountNamespaces` に対象アカウントが存在する selector がある場合、opencodex は対応するアカウントごとに `/` 行を追加し、ピッカーでは bare native 行を非表示にします。Selector 名はユーザーが決める公開ラベルであり、組み込みのアカウント role の意味はありません。`selector` 付きの行を選択すると、対応付けられたアカウントだけが使用され、アクティブな Pool アカウントは変更されません。対象を利用できない場合、別のアカウントへ切り替えずにリクエストが失敗します。詳しくは [Codex アカウントの明示的な selector](/reference/configuration/routing/#exact-codex-account-selectors) を参照してください。API GPT-5.6 エントリは 1,050,000 コンテキスト / 922,000 最大入力を使用し、`*-pro` ピッカー ID は `reasoning.mode: "pro"` のベース ワイヤ モデルに解決されますが、ログ、使用状況、およびピッカー状態は仮想 ID を保持します。 API カタログは、`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、およびそれらの 3 つの Pro 仮想 ID の 8 つの ID に固定されています。汎用の `gpt-5.6-pro` エイリアスはありません。コンパクト リクエストは、選択された層を保持しますが、推論オブジェクトなしで基本モデルを送信します。 +OpenAI エントリには、ネイティブ Codex ログインと、名前空間付きの `openai-apikey/` API キーという 2 つの資格情報ルートがあります。`codexAccountMode` だけを Pool と Direct の間で変更しても、ピッカー ID は変わりません。ただし、`codexAccountPickerEnabled` によって account-qualified picker 行が有効で、`codexAccountNamespaces` に対象アカウントが存在する selector がある場合、opencodex は対応するアカウントごとに `/` 行を追加し、ピッカーでは bare native 行を非表示にします。Selector 名はユーザーが決める公開ラベルであり、組み込みのアカウント role の意味はありません。`selector` 付きの行を選択すると、対応付けられたアカウントだけが使用され、アクティブな Pool アカウントは変更されません。対象を利用できない場合、別のアカウントへ切り替えずにリクエストが失敗します。詳しくは [Codex アカウントの明示的な selector](/reference/configuration/routing/#exact-codex-account-selectors) を参照してください。 + +`codexAccountNamespaces` map が空の場合、account-qualified picker 行は off です。空でない map で `codexAccountPickerEnabled` を省略すると、後方互換性のため有効として扱われます。`false` にすると、mapping を削除せず、明示的な `/` routing も無効にせずに、生成された qualified 行を非表示にして picker の bare native 行を復元します。 + +API GPT-5.6 エントリは 1,050,000 コンテキスト / 922,000 最大入力を使用し、`*-pro` ピッカー ID は `reasoning.mode: "pro"` のベース ワイヤ モデルに解決されますが、ログ、使用状況、およびピッカー状態は仮想 ID を保持します。 API カタログは、`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、およびそれらの 3 つの Pro 仮想 ID の 8 つの ID に固定されています。汎用の `gpt-5.6-pro` エイリアスはありません。コンパクト リクエストは、選択された層を保持しますが、推論オブジェクトなしで基本モデルを送信します。 ピッカー ID で資格情報ルートを明示的に選択します。Pool/Direct は Providers ページで変更します。以下の `` は、`codexAccountNamespaces` で対応付けたユーザー定義の公開ラベルです。 @@ -45,8 +49,8 @@ visibility = "list" |ルート |ピッカー ID とカタログのメタデータ | | --- | --- | -| Codex ログイン (有効な account selector なし) | `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` などの bare native id を表示し、`codexAccountMode` に従って Pool または Direct を使用します。GPT-5.6 行のカタログ ウィンドウは 372,000 トークンです。 | -| Codex ログイン (有効な account selector あり) | 有効な selector とサポート対象 native model の各組み合わせに `/` 行を表示します。各行は対応付けられたアカウントだけを使用し、bare native 行はピッカーで非表示になります。Native metadata と context window は保持されます。 | +| Codex ログイン (account-qualified 行が無効) | `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` などの bare native id を表示し、`codexAccountMode` に従って Pool または Direct を使用します。GPT-5.6 行のカタログ ウィンドウは 372,000 トークンです。 | +| Codex ログイン (account-qualified 行が有効で、有効な selector あり) | 有効な selector とサポート対象 native model の各組み合わせに `/` 行を表示します。各行は対応付けられたアカウントだけを使用し、bare native 行はピッカーで非表示になります。Native metadata と context window は保持されます。 | | OpenAI (API キー) |正確に 8 つの名前空間行: `gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、および 3 つの `*-pro` 仮想 ID (コンテキスト 1,050,000、8 つすべての最大入力 922,000) | |オープンルーター | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna` (1,050,000) | |カーソル |静的フォールバックには、`cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、および `cursor/gpt-5.6-luna` (1,000,000)、さらに `cursor/grok-4.5` および `cursor/grok-4.5-fast` (500,000) が含まれます。ライブアカウントの検出により、どれが表示されたままになるかが決まります。 | @@ -97,7 +101,7 @@ fast_mode = true ## サブエージェントの選択 -Codex は、ピッカーに表示されるカタログ エントリを `priority` の昇順で並べ替え、最初の 5 つを `spawn_agent` モデル オーバーライドとしてアドバタイズします。ダッシュボードの Subagents ページでは、bare native id または routed `provider/model` id を最大 5 つ選択して保存できます。手動で設定した `subagentModels` は account-qualified `/` id も受け付けますが、ダッシュボードにはこれらの exact id が表示されません。ページを保存すると、リストはダッシュボードに表示される選択肢で置き換えられます。opencodex は選択順に低いカタログ priority を割り当てます。account selector が有効な場合、bare native の選択は selector-qualified グループに展開されます。他のモデルは引き続き正確な ID で呼び出すことができます。 +Codex は、ピッカーに表示されるカタログ エントリを `priority` の昇順で並べ替え、最初の 5 つを `spawn_agent` モデル オーバーライドとしてアドバタイズします。ダッシュボードの Subagents ページでは、bare native id または routed `provider/model` id を最大 5 つ選択して保存できます。手動で設定した `subagentModels` は account-qualified `/` id も受け付けますが、ダッシュボードにはこれらの exact id が表示されません。ページを保存すると、リストはダッシュボードに表示される選択肢で置き換えられます。opencodex は選択順に低いカタログ priority を割り当てます。account-qualified picker 行が有効な場合、bare native の選択は selector-qualified グループに展開されます。他のモデルは引き続き正確な ID で呼び出すことができます。 注目モデルのリストは、ダッシュボードの **サブエージェント委任** の選択とは別のものです。 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 d2ac1dfc8..fd23954cc 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -16,7 +16,8 @@ description: プロバイダー エントリ、認証、エンドポイント、 | `contextCapValue?` | `number` | `350000` |ダッシュボードのコンテキストキャップ コントロールで使用される値。これを変更すると、有効になっているすべての `providerContextCaps` エントリが更新されます。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex プール アカウントのメタデータは Codex Auth によって管理されます。秘密は`codex-accounts.json`に別に住んでいます。 | | `pausedCodexAccountIds?` | `string[]` | `[]` |再開するまでプールの選択から除外されるアカウント (一時停止時のメイン `__main__` アカウントを含む)。 | -| `codexAccountNamespaces?` | `Record` | — | 任意の公開 model selector を保存済み Codex アカウント target に対応付ける任意の map。target が存在する各 selector は Codex picker に個別の `/` row を追加し、各 row はそのアカウントだけを使用します。selector が 1 つでも有効な場合、bare native row は picker で非表示になりますが、明示的に無効化されない限り id は引き続き routing でき、raw `/v1/models` にも表示されます。 | +| `codexAccountNamespaces?` | `Record` | — | 任意の公開 model selector を保存済み Codex アカウント target に対応付ける任意の map。account-qualified picker row が有効な場合、target が存在する各 selector は Codex picker に個別の `/` row を追加し、各 row はそのアカウントだけを使用します。selector が 1 つでも有効な場合、bare native row は picker で非表示になりますが、明示的に無効化されない限り id は引き続き routing でき、raw `/v1/models` にも表示されます。 | +| `codexAccountPickerEnabled?` | `boolean` | map が空なら off | 有効な `codexAccountNamespaces` mapping から account-qualified Codex picker row を生成するかを制御します。`true` は mapping された行の表示を許可します。空でない map で省略した場合は後方互換性のため有効として扱われ、map が空なら off です。`false` は mapping を削除せず、明示的な `/` routing も無効にせずに、生成行を非表示にして picker の bare native 行を復元します。 | | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | | `codexAccountPriorities?` | `Record` | — | Codex pool のアカウント別選択順。アカウント ID → `-100` から `100` の整数で、**大きいほど先に使われ**、未設定は `0` です。これは eligibility ではなく順序の境界です。選択は適格なアカウントを、まだ quota に余裕がある最上位 tier に絞り込み、その tier の中を `accountPoolStrategy` が選びます。tier が飛ばされるのは、そのメンバー全員が `autoSwitchThreshold` 超過、cooldown 中、soft-avoid、一時停止、または再認証待ちのときだけで、usage 不明が tier を drain させることはありません。順序付けが不適格なアカウントを選択可能にすることはなく、すでにアカウントが結び付いた thread を再 bind することもありません。メインの `__main__` も同じ条件で参加するため、Codex Desktop ログインを最後に使わせられます。エントリが 1 つもなければ挙動は従来どおりです。map が不正な場合は警告を出して順序付けを無効にします(config の修復処理は走りません)。`ocx account priority` と Codex Auth ページで管理します。 | | `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は紐付け済み/未紐付けタスクの次のリクエストを再評価でき、`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index 4d0f40370..4083aa3a9 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -9,12 +9,20 @@ opencodex는 Codex App을 직접 고치지 않습니다. Codex CLI/TUI가 이미 OpenAI 항목에는 네이티브 Codex 로그인과 네임스페이스가 붙은 `openai-apikey/` API key 경로라는 두 가지 credential 경로가 있습니다. `codexAccountMode`만 Pool과 Direct 사이에서 바꾸는 것은 -선택기 id를 바꾸지 않습니다. 하지만 `codexAccountNamespaces`에 대상 계정이 존재하는 selector가 있으면, +선택기 id를 바꾸지 않습니다. 하지만 `codexAccountPickerEnabled`로 계정 한정 선택기 행이 활성화되어 있고 +`codexAccountNamespaces`에 대상 계정이 존재하는 selector가 있으면, opencodex는 매핑된 계정별로 `/` 행을 추가하고 선택기에서 bare native 행을 숨깁니다. Selector 이름은 사용자가 정하는 공개 label이며 내장된 계정 역할 의미가 없습니다. `selector`가 붙은 행을 선택하면 매핑된 계정만 사용하고 활성 Pool 계정은 바뀌지 않습니다. 대상 계정을 사용할 수 없으면 다른 계정으로 전환하지 않고 요청이 실패합니다. 자세한 내용은 [명시적 Codex 계정 selector](/reference/configuration/routing/#exact-codex-account-selectors)를 -참고하세요. API GPT-5.6 항목은 context 1,050,000 / max input 922,000을 +참고하세요. + +`codexAccountNamespaces` map이 비어 있으면 계정 한정 선택기 행은 꺼집니다. 비어 있지 않은 map에서 +`codexAccountPickerEnabled`를 생략하면 이전 버전과의 호환성을 위해 활성화된 것으로 취급됩니다. `false`로 +설정하면 매핑을 삭제하거나 명시적 `/` 라우팅을 비활성화하지 않은 채 생성된 +qualified 행을 숨기고 선택기에 bare native 행을 복원합니다. + +API GPT-5.6 항목은 context 1,050,000 / max input 922,000을 쓰고, `*-pro` picker id는 로그, 사용량, picker 상태에는 가상 id를 유지한 채 wire에서는 base model과 `reasoning.mode: "pro"`로 풀립니다. API 카탈로그는 `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, 그리고 세 개의 Pro 가상 id까지 정확히 여덟 개로 고정되어 있으며, 일반적인 `gpt-5.6-pro` 별칭은 없습니다. Compact 요청은 @@ -70,8 +78,8 @@ GPT-5.6에만 사용합니다. 오래된 템플릿으로 근사하지 않고 모 | 경로 | 선택기 id와 카탈로그 메타데이터 | | --- | --- | -| Codex 로그인(유효한 account selector 없음) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` 같은 bare native id를 표시하고 `codexAccountMode`에 따라 Pool 또는 Direct를 사용합니다. GPT-5.6 행의 카탈로그 창은 372,000토큰입니다. | -| Codex 로그인(유효한 account selector 있음) | 유효한 selector와 지원되는 native model의 각 조합마다 `/` 행을 표시합니다. 각 행은 매핑된 계정만 사용하며 bare native 행은 선택기에서 숨깁니다. Native metadata와 context window는 보존됩니다. | +| Codex 로그인(계정 한정 선택기 행 비활성) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` 같은 bare native id를 표시하고 `codexAccountMode`에 따라 Pool 또는 Direct를 사용합니다. GPT-5.6 행의 카탈로그 창은 372,000토큰입니다. | +| Codex 로그인(계정 한정 선택기 행 활성, 유효한 selector 있음) | 유효한 selector와 지원되는 native model의 각 조합마다 `/` 행을 표시합니다. 각 행은 매핑된 계정만 사용하며 bare native 행은 선택기에서 숨깁니다. Native metadata와 context window는 보존됩니다. | | OpenAI(API key) | 정확히 여덟 개의 네임스페이스 행: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, 그리고 세 개의 `*-pro` 가상 id (모두 컨텍스트 1,050,000; 최대 입력 922,000) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (1,050,000) | | Cursor | 정적 폴백에는 `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` (1,000,000)와 `cursor/grok-4.5`, `cursor/grok-4.5-fast` (500,000)가 들어갑니다. 실시간 계정 탐색이 어떤 항목을 계속 보일지 정합니다. | @@ -143,7 +151,7 @@ Codex는 선택기에 보이는 카탈로그 항목을 `priority` 오름차순 routed `provider/model` id를 최대 다섯 개 선택하고 저장할 수 있습니다. 수동으로 설정한 `subagentModels`는 account-qualified `/` id도 지원하지만, 대시보드는 이러한 exact id를 제공하지 않으며 페이지를 저장하면 목록이 대시보드에 표시되는 선택 항목으로 -교체됩니다. opencodex는 선택한 순서대로 낮은 카탈로그 priority를 부여합니다. account selector가 +교체됩니다. opencodex는 선택한 순서대로 낮은 카탈로그 priority를 부여합니다. 계정 한정 선택기 행이 활성화되어 있으면 bare native 선택은 selector-qualified 그룹으로 확장됩니다. 다른 모델도 정확한 id로 직접 호출할 수 있습니다. 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 77ba9a95f..ea20120d9 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -16,7 +16,8 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 | `contextCapValue?` | `number` | `350000` | 대시보드의 컨텍스트 상한 컨트롤이 사용하는 값입니다. 이 값을 바꾸면 활성화된 모든 `providerContextCaps` 항목이 함께 갱신됩니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth가 관리하는 ChatGPT/Codex 풀 계정 메타데이터입니다. 비밀 정보는 `codex-accounts.json`에 따로 저장됩니다. | | `pausedCodexAccountIds?` | `string[]` | `[]` | 일시 중지된 `__main__` 계정을 포함해, 재개될 때까지 Pool 선택에서 제외되는 계정입니다. | -| `codexAccountNamespaces?` | `Record` | — | 임의의 공개 model selector를 저장된 Codex 계정 target에 연결하는 선택적 map입니다. target이 존재하는 각 selector는 Codex picker에 별도의 `/` row를 추가하며, 각 row는 해당 계정만 사용합니다. selector가 하나라도 활성화되면 bare native row는 picker에서 숨겨지지만, 명시적으로 비활성화하지 않는 한 해당 id는 계속 routing 가능하고 raw `/v1/models`에 표시됩니다. | +| `codexAccountNamespaces?` | `Record` | — | 임의의 공개 model selector를 저장된 Codex 계정 target에 연결하는 선택적 map입니다. 계정 한정 선택기 행이 활성화되어 있으면 target이 존재하는 각 selector는 Codex picker에 별도의 `/` row를 추가하며, 각 row는 해당 계정만 사용합니다. selector가 하나라도 활성화되면 bare native row는 picker에서 숨겨지지만, 명시적으로 비활성화하지 않는 한 해당 id는 계속 routing 가능하고 raw `/v1/models`에 표시됩니다. | +| `codexAccountPickerEnabled?` | `boolean` | map이 비어 있으면 꺼짐 | 유효한 `codexAccountNamespaces` 매핑에서 account-qualified Codex 선택기 행을 생성할지 제어합니다. `true`는 매핑된 행의 표시를 허용합니다. 비어 있지 않은 map에서 생략하면 이전 버전과의 호환성을 위해 활성화된 것으로 취급되며, map이 비어 있으면 꺼집니다. `false`는 매핑을 삭제하거나 명시적 `/` 라우팅을 비활성화하지 않은 채 생성 행을 숨기고 선택기에 bare native 행을 복원합니다. | | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | | `codexAccountPriorities?` | `Record` | — | Codex pool의 계정별 선택 순서. 계정 ID → `-100`부터 `100`까지의 정수이며 **값이 클수록 먼저** 쓰이고, 항목이 없으면 `0`입니다. 이는 eligibility 경계가 아니라 순서 경계입니다. 선택은 이미 적격한 계정들을 quota 여유가 남은 최상위 tier로 좁히고, 그 tier 안에서 `accountPoolStrategy`가 계정을 고릅니다. tier를 건너뛰는 경우는 그 구성원 전부가 `autoSwitchThreshold` 초과, cooldown, soft-avoid, 일시 중지 또는 재인증 대기일 때뿐이며, usage를 알 수 없다고 해서 tier가 소진되지는 않습니다. 순서는 부적격 계정을 선택 가능하게 만들지 않고, 이미 계정에 묶인 thread를 다시 bind하지도 않습니다. 메인 `__main__` 계정도 동일한 조건으로 참여하므로 Codex Desktop 로그인을 마지막에 쓰도록 둘 수 있습니다. 항목이 하나도 없으면 동작은 이전과 같습니다. map이 잘못된 경우 경고를 출력하고 순서 지정을 끕니다(config 복구는 하지 않습니다). `ocx account priority`와 Codex Auth 페이지에서 관리합니다. | | `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩된 작업과 바인딩 없는 작업의 다음 요청을 모두 재평가할 수 있고, `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 4849f1d3a..29cdef5b0 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -17,7 +17,8 @@ authenticated. | `contextCapValue?` | `number` | `350000` | Value used by the dashboard context-cap controls; changing it updates every enabled `providerContextCaps` entry. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. | -| `codexAccountNamespaces?` | `Record` | — | Optional map from an arbitrary public model selector to a stored Codex account target. Each selector whose target is present adds separate `/` rows to the Codex picker; each row uses only that account. With any selector active, bare native rows are hidden in the picker, but their ids remain routable and listed by raw `/v1/models` unless explicitly disabled. | +| `codexAccountNamespaces?` | `Record` | — | Optional map from an arbitrary public model selector to a stored Codex account target. When account-qualified picker rows are enabled, each selector whose target is present adds separate `/` rows to the Codex picker; each row uses only that account. With any selector active, bare native rows are hidden in the picker, but their ids remain routable and listed by raw `/v1/models` unless explicitly disabled. | +| `codexAccountPickerEnabled?` | `boolean` | off when the map is empty | Controls whether eligible `codexAccountNamespaces` mappings generate account-qualified Codex picker rows. `true` allows mapped rows to appear. If omitted with a non-empty map, it is treated as enabled for backward compatibility; if the map is empty, it is off. `false` hides generated rows and restores bare native picker rows without deleting mappings or disabling exact `/` routing. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account for the next request. Selection clears thread affinity; in-flight requests keep captured credentials. | | `codexAccountPriorities?` | `Record` | — | Per-account selection order for the Codex pool: account id → integer from `-100` to `100`, **higher is used earlier**, absent means `0`. This is an ordering boundary, not an eligibility one: selection narrows the already-eligible accounts to the highest tier that still has quota headroom, and `accountPoolStrategy` then picks within that tier. A tier is skipped only when every member is over `autoSwitchThreshold`, cooling down, soft-avoided, paused, or needs reauthentication — unknown quota never drains a tier. Ordering never makes an ineligible account selectable and never re-binds a thread that already has an account. The main `__main__` account participates on equal terms, which is how the Codex Desktop login can be set to drain last. With no entries the pool behaves exactly as before. A malformed map is ignored with a console warning (ordering off, no config repair). Managed by `ocx account priority` and the Codex Auth page. | | `activeCodexAccountPinned?` | `string` | — | Account id the operator last selected by hand. While set, a higher `codexAccountPriorities` tier cannot preempt it until the pin is released by drain, exclusion, deletion, or an explicit failover/promotion away. Ordinary round-robin movement inside the capped tier does not release it. Writing any `codexAccountPriorities` entry also releases the pin, so a pin made before an order existed cannot outrank one set afterward. `GET /api/codex-auth/active` reports both whether the effective account is pinned (`pinned`) and the account carrying the ceiling (`pinnedAccountId`). | diff --git a/docs-site/src/content/docs/ru/guides/codex-app-models.md b/docs-site/src/content/docs/ru/guides/codex-app-models.md index a93dca200..9d299bf1a 100644 --- a/docs-site/src/content/docs/ru/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ru/guides/codex-app-models.md @@ -9,13 +9,22 @@ opencodex не патчит Codex App. Он записывает ту же ко Записи OpenAI используют два credential-транспорта: нативный вход Codex и namespaced-транспорт API-ключа `openai-apikey/`. Само по себе переключение `codexAccountMode` между Pool и Direct -не меняет id в picker'е. Однако если в `codexAccountNamespaces` есть подходящие селекторы, +не меняет id в picker'е. Однако если `codexAccountPickerEnabled` включает строки picker'а с +указанием аккаунта и в +`codexAccountNamespaces` есть подходящие селекторы, opencodex добавляет для сопоставленных аккаунтов отдельные строки `/` и скрывает bare native-строки из picker'а. Имена селекторов — это публичные метки, которые выбирает пользователь; встроенного смысла роли аккаунта у них нет. Выбор строки с селектором использует только сопоставленный аккаунт, не меняет активный аккаунт Pool и при недоступности цели завершается ошибкой без переключения на другой аккаунт. Подробнее см. в разделе [Точные селекторы аккаунтов Codex](/reference/configuration/routing/#exact-codex-account-selectors). + +Если map `codexAccountNamespaces` пуста, строки picker'а с указанием аккаунта выключены. Если при +непустой map поле `codexAccountPickerEnabled` не задано, они считаются включёнными для обратной +совместимости. Значение `false` скрывает созданные account-qualified строки и возвращает bare +native-строки в picker, не удаляя сопоставления и не отключая точную маршрутизацию +`/`. + У строк API GPT-5.6 — контекст 1,050,000 и максимум входа 922,000; id picker'а вида `*-pro` разрешаются в базовую wire-модель с `reasoning.mode: "pro"`, а логи, usage и picker state сохраняют виртуальный @@ -75,8 +84,8 @@ per-model identity и метаданные вместо приближения | Маршрут | Id в селекторе и метаданные каталога | | --- | --- | -| Вход Codex (без подходящих селекторов аккаунтов) | Bare native-id, например `gpt-5.6-sol`, `gpt-5.6-terra` и `gpt-5.6-luna`; Pool или Direct выбирается через `codexAccountMode`. У строк GPT-5.6 окно каталога 372 000 токенов. | -| Вход Codex (с подходящими селекторами аккаунтов) | По одной строке `/` для каждой пары подходящего селектора и поддерживаемой нативной модели; каждая строка использует только сопоставленный аккаунт, а bare native-строки скрыты из picker'а. Нативные метаданные и окна контекста сохраняются. | +| Вход Codex (строки с указанием аккаунта выключены) | Bare native-id, например `gpt-5.6-sol`, `gpt-5.6-terra` и `gpt-5.6-luna`; Pool или Direct выбирается через `codexAccountMode`. У строк GPT-5.6 окно каталога 372 000 токенов. | +| Вход Codex (строки с указанием аккаунта включены и есть подходящие селекторы) | По одной строке `/` для каждой пары подходящего селектора и поддерживаемой нативной модели; каждая строка использует только сопоставленный аккаунт, а bare native-строки скрыты из picker'а. Нативные метаданные и окна контекста сохраняются. | | OpenAI (API key) | Ровно восемь namespaced-строк: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna и три виртуальных id `*-pro` (контекст 1,050,000; максимум входа 922,000 у всех восьми) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (1,050,000) | | Cursor | Статический fallback включает `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra` и `cursor/gpt-5.6-luna` (1,000,000), а также `cursor/grok-4.5` и `cursor/grok-4.5-fast` (500,000); какие из них останутся видимыми, решает live-discovery аккаунта. | @@ -150,8 +159,8 @@ Codex сортирует видимые в picker'е записи каталог сохранить до пяти bare native-id или routed provider-id `provider/model`. Настроенный вручную `subagentModels` также принимает account-qualified id `/`, но дашборд не предлагает эти точные id; сохранение страницы заменяет список вариантами, доступными в -дашборде. opencodex назначает им низкие приоритеты каталога в выбранном порядке; при активных -селекторах аккаунтов bare native-выбор разворачивается в группы selector-qualified строк. Остальные +дашборде. opencodex назначает им низкие приоритеты каталога в выбранном порядке; при включённых +строках picker'а с указанием аккаунта bare native-выбор разворачивается в группы selector-qualified строк. Остальные модели всё равно можно вызывать по точному id. Список featured-моделей отделён от выбора **Sub-agent delegation** в дашборде. Он только 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 99ab4fab1..3cf192439 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -17,7 +17,8 @@ description: Записи провайдеров, аутентификация, | `contextCapValue?` | `number` | `350000` | Значение, используемое элементами управления context-cap в дашборде; его изменение обновляет все включённые записи `providerContextCaps`. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, которыми управляет Codex Auth. Секреты живут отдельно в `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Аккаунты, исключённые из выбора Pool до снятия паузы, включая основной аккаунт `__main__`, если он поставлен на паузу. | -| `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление произвольного публичного селектора модели с сохранённым аккаунтом Codex. Каждый селектор с существующей целью добавляет в model picker Codex отдельные строки `/`; каждая строка использует только этот аккаунт. Если активен хотя бы один селектор, bare native-строки скрываются в picker, но их id остаются маршрутизируемыми и перечисляются raw `/v1/models`, если они не отключены явно. | +| `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление произвольного публичного селектора модели с сохранённым аккаунтом Codex. Когда строки picker'а с указанием аккаунта включены, каждый селектор с существующей целью добавляет в model picker Codex отдельные строки `/`; каждая строка использует только этот аккаунт. Если активен хотя бы один селектор, bare native-строки скрываются в picker, но их id остаются маршрутизируемыми и перечисляются raw `/v1/models`, если они не отключены явно. | +| `codexAccountPickerEnabled?` | `boolean` | выкл. при пустой map | Управляет созданием account-qualified строк picker'а Codex из подходящих сопоставлений `codexAccountNamespaces`. `true` разрешает показывать сопоставленные строки. Если поле не задано при непустой map, функция считается включённой для обратной совместимости; при пустой map она выключена. `false` скрывает созданные строки и возвращает bare native-строки в picker, не удаляя сопоставления и не отключая точную маршрутизацию `/`. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | | `codexAccountPriorities?` | `Record` | — | Порядок выбора для каждого аккаунта пула Codex: id аккаунта → целое число от `-100` до `100`, **больше — используется раньше**, отсутствие означает `0`. Это граница порядка, а не пригодности: выбор сужает уже подходящие аккаунты до самого высокого уровня, у которого ещё есть запас квоты, а внутри этого уровня аккаунт выбирает `accountPoolStrategy`. Уровень пропускается, только когда все его аккаунты превысили `autoSwitchThreshold`, находятся в cooldown, под soft-avoid, на паузе или требуют повторной аутентификации; неизвестный usage никогда не исчерпывает уровень. Порядок не делает выбираемым непригодный аккаунт и не перепривязывает поток, у которого аккаунт уже есть. Основной аккаунт `__main__` участвует на равных — именно так логин Codex Desktop можно оставить на самый конец. Без записей поведение остаётся прежним. Некорректная map игнорируется с предупреждением в консоли (порядок отключается, восстановление config не запускается). Управляется через `ocx account priority` и страницу Codex Auth. | | `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий запрос как привязанной, так и непривязанной задачи; `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md index 99dfa7e5e..da5a2400a 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md @@ -5,7 +5,11 @@ description: opencodex 中的模型如何通过共享 Codex 目录出现在 Code opencodex 不会修改 Codex App。它会写入 Codex CLI/TUI 已经使用的同一套 Codex 配置和模型目录。因为 Codex App 读取的是这份共享状态,路由模型可以像普通 Codex 目录条目一样出现在 App 的模型选择器中。 -OpenAI 条目有两种凭据通道:原生 Codex 登录,以及命名空间化的 `openai-apikey/` API key 通道。仅在 Pool 与 Direct 之间切换 `codexAccountMode` 不会改变选择器 id。但当 `codexAccountNamespaces` 中有目标账户存在的 selector 时,opencodex 会为映射账户添加独立的 `/` 行,并在选择器中隐藏裸原生行。Selector 名称是用户自定义的公开标签,没有内置的账户角色含义。选择带 `selector` 的行只会使用映射账户,不会更改当前 Pool 账户;目标不可用时,请求会直接失败,不会切换到其他账户。详情请参阅[精确 Codex 账户选择器](/reference/configuration/routing/#exact-codex-account-selectors)。API GPT-5.6 条目使用 1,050,000 context / 922,000 max input,而 `*-pro` 选择器 id 会解析到基础线协议模型,并在日志、用量和选择器状态中保留虚拟 id,同时带上 `reasoning.mode: "pro"`。API 目录固定为恰好八个 id:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna,以及它们三个 Pro 虚拟 id;不存在通用的 `gpt-5.6-pro` 别名。Compact 请求会保留所选 tier,但发送基础模型且不带 reasoning 对象。 +OpenAI 条目有两种凭据通道:原生 Codex 登录,以及命名空间化的 `openai-apikey/` API key 通道。仅在 Pool 与 Direct 之间切换 `codexAccountMode` 不会改变选择器 id。但当 `codexAccountPickerEnabled` 启用了账户限定的选择器行,且 `codexAccountNamespaces` 中有目标账户存在的 selector 时,opencodex 会为映射账户添加独立的 `/` 行,并在选择器中隐藏裸原生行。Selector 名称是用户自定义的公开标签,没有内置的账户角色含义。选择带 `selector` 的行只会使用映射账户,不会更改当前 Pool 账户;目标不可用时,请求会直接失败,不会切换到其他账户。详情请参阅[精确 Codex 账户选择器](/reference/configuration/routing/#exact-codex-account-selectors)。 + +`codexAccountNamespaces` 映射为空时,账户限定的选择器行处于关闭状态。非空映射中省略 `codexAccountPickerEnabled` 时,为保持向后兼容会视为已启用。设为 `false` 会隐藏生成的账户限定行并恢复选择器中的裸原生行,但不会删除映射,也不会禁用精确的 `/` 路由。 + +API GPT-5.6 条目使用 1,050,000 context / 922,000 max input,而 `*-pro` 选择器 id 会解析到基础线协议模型,并在日志、用量和选择器状态中保留虚拟 id,同时带上 `reasoning.mode: "pro"`。API 目录固定为恰好八个 id:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna,以及它们三个 Pro 虚拟 id;不存在通用的 `gpt-5.6-pro` 别名。Compact 请求会保留所选 tier,但发送基础模型且不带 reasoning 对象。 请通过选择器 id 显式选择凭据路径。在 Providers 页面切换 Pool/Direct;下面的 `` 是 用户自定义、通过 `codexAccountNamespaces` 映射的公开标签: @@ -46,8 +50,8 @@ visibility = "list" | 路由 | 选择器 id 与目录元数据 | | --- | --- | -| Codex 登录(没有有效账户 selector) | 显示 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 等裸原生 id,并按 `codexAccountMode` 使用 Pool 或 Direct。GPT-5.6 行使用 372,000-token 目录窗口。 | -| Codex 登录(有有效账户 selector) | 为每个有效 selector 与受支持原生模型的组合显示 `/` 行。每行只使用映射账户,裸原生行会从选择器中隐藏。原生 metadata 与 context window 会保留。 | +| Codex 登录(账户限定的选择器行未启用) | 显示 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 等裸原生 id,并按 `codexAccountMode` 使用 Pool 或 Direct。GPT-5.6 行使用 372,000-token 目录窗口。 | +| Codex 登录(账户限定的选择器行已启用且存在有效 selector) | 为每个有效 selector 与受支持原生模型的组合显示 `/` 行。每行只使用映射账户,裸原生行会从选择器中隐藏。原生 metadata 与 context window 会保留。 | | OpenAI(API key) | 恰好八个命名空间行:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna,以及三个 `*-pro` 虚拟 id(八个条目均为 1,050,000 context / 922,000 max input) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna`(1,050,000) | | Cursor | 静态回退包含 `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna`(1,000,000),以及 `cursor/grok-4.5` 和 `cursor/grok-4.5-fast`(500,000);实时账户发现会决定最终哪些条目仍然可见。 | @@ -96,7 +100,7 @@ fast_mode = true ## 子代理选择 -Codex 会按 `priority` 升序对选择器可见的目录条目排序,并把前五个作为 `spawn_agent` 模型 override 暴露出来。仪表盘 Subagents 页面最多可以选择并保存五个裸原生 id 或路由 `provider/model` id。手动设置的 `subagentModels` 也支持账户限定的 `/` id,但仪表盘不会提供这些精确 id;保存该页面会用仪表盘中可见的选项替换整个列表。opencodex 会按所选顺序分配较低的目录 priority;启用账户 selector 时,裸原生选择会展开为 selector-qualified 分组。其他模型仍然可以通过精确 id 调用。 +Codex 会按 `priority` 升序对选择器可见的目录条目排序,并把前五个作为 `spawn_agent` 模型 override 暴露出来。仪表盘 Subagents 页面最多可以选择并保存五个裸原生 id 或路由 `provider/model` id。手动设置的 `subagentModels` 也支持账户限定的 `/` id,但仪表盘不会提供这些精确 id;保存该页面会用仪表盘中可见的选项替换整个列表。opencodex 会按所选顺序分配较低的目录 priority;启用账户限定的选择器行时,裸原生选择会展开为 selector-qualified 分组。其他模型仍然可以通过精确 id 调用。 精选模型列表与 Dashboard 的 **Sub-agent delegation** 选择彼此独立。它只决定 Codex 先提供哪些 override;它不会自己选择模型,也不会触发委派。 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 becca0706..971552c97 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 @@ -16,7 +16,8 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 | `contextCapValue?` | `number` | `350000` | 仪表板上下文上限控件使用的值;修改它会更新所有已启用的 `providerContextCaps` 条目。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池账户元数据。密钥单独存放在 `codex-accounts.json` 中。 | | `pausedCodexAccountIds?` | `string[]` | `[]` | 在恢复之前从 Pool 选择中排除的账户,包括被暂停时的主 `__main__` 账户。 | -| `codexAccountNamespaces?` | `Record` | — | 将任意公开 model selector 映射到已保存 Codex account target 的可选配置。target 存在的每个 selector 都会在 Codex picker 中添加独立的 `/` row,且每个 row 只使用对应账户。只要有 selector 生效,bare native row 就会在 picker 中隐藏;但除非显式禁用,其 id 仍可路由,并继续列在 raw `/v1/models` 中。 | +| `codexAccountNamespaces?` | `Record` | — | 将任意公开 model selector 映射到已保存 Codex account target 的可选配置。启用账户限定的选择器行后,target 存在的每个 selector 都会在 Codex picker 中添加独立的 `/` row,且每个 row 只使用对应账户。只要有 selector 生效,bare native row 就会在 picker 中隐藏;但除非显式禁用,其 id 仍可路由,并继续列在 raw `/v1/models` 中。 | +| `codexAccountPickerEnabled?` | `boolean` | 映射为空时关闭 | 控制是否根据有效的 `codexAccountNamespaces` 映射生成账户限定的 Codex 选择器行。`true` 允许显示映射行。在非空映射中省略此字段时,为保持向后兼容会视为已启用;映射为空时则关闭。`false` 会隐藏生成行并恢复选择器中的裸原生行,但不会删除映射,也不会禁用精确的 `/` 路由。 | | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | | `codexAccountPriorities?` | `Record` | — | Codex pool 各账号的选择顺序:账号 ID → `-100` 到 `100` 的整数,**数值越大越先使用**,未设置即为 `0`。这是顺序边界而非资格边界:选择会把已经合格的账号收窄到仍有 quota 余量的最高 tier,再由 `accountPoolStrategy` 在该 tier 内挑选。只有当某个 tier 的所有成员都超过 `autoSwitchThreshold`、处于 cooldown、被 soft-avoid、已暂停或需要重新认证时,该 tier 才会被跳过;usage 未知不会让 tier 耗尽。顺序不会让不合格的账号变得可选,也不会重新绑定已经绑定账号的 thread。主账号 `__main__` 同样参与排序,因此可以让 Codex Desktop 登录账号最后才被用到。没有任何条目时,行为与以往完全一致。映射格式非法时会打印警告并关闭排序(不会触发 config 修复)。可通过 `ocx account priority` 和 Codex Auth 页面管理。 | | `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估已绑定和未绑定任务;`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 3ee398ecf..9e2cb0b94 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -8,6 +8,7 @@ export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, c export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch"; export { deriveComboCatalogModel, exactComboCatalogSlugs, getLastComboCatalogOmissions, resetOpenAiApiCatalogWarningStateForTests, uniqueCatalogModelsForPublicList, uniqueCatalogModelsForRawPublicList, buildComboCatalogOmission, comboCatalogOmissionReason, summarizeComboCatalogOmissions } from "./catalog/aggregation"; export type { ComboCatalogOmission, ComboCatalogOmissionReason } from "./catalog/aggregation"; -export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, effectiveSubagentRoster, buildCatalogEntries, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; +export { MAX_SPAWN_AGENT_MODEL_OVERRIDES, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, effectiveSubagentRoster, buildCatalogEntries, mergeCatalogEntriesFromObservedState, resetCatalogRuntimeStateForTests, orderForSubagents, mergeCatalogEntriesForSync, syncCatalogModels, restoreCodexCatalog, invalidateCodexModelsCache } from "./catalog/sync"; +export type { ObservedCatalogMergeInput } from "./catalog/sync"; export type { SpawnAgentSurface, SubagentRosterExclusionReason, EffectiveSubagentModel, SubagentRosterExclusion, EffectiveSubagentRoster } from "./catalog/sync"; export { accountBoundNativeDisplayName, accountBoundNativeModelSlugs, CODEX_ACCOUNT_BOUND_CATALOG_KIND, trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors } from "./catalog/account-models"; diff --git a/src/codex/catalog/account-models.ts b/src/codex/catalog/account-models.ts index 300000293..e46a5bd27 100644 --- a/src/codex/catalog/account-models.ts +++ b/src/codex/catalog/account-models.ts @@ -1,5 +1,9 @@ import type { OcxConfig } from "../../types"; -import { isMainCodexAccountTarget } from "../account-namespaces"; +import { + codexAccountNamespaceEntries, + codexAccountPickerEnabled, + isMainCodexAccountTarget, +} from "../account-namespaces"; import type { RawEntry } from "./parsing"; /** Stable nonsemantic marker used to distinguish generated rows from provider-owned rows. */ @@ -14,14 +18,15 @@ export const CODEX_ACCOUNT_BOUND_CATALOG_KIND = "account-selector-v1"; * namespace validation. Only those public keys leave this boundary; private account ids do not. */ export function visibleCodexAccountSelectors( - config: Pick, + config: Pick, ): string[] { + if (!codexAccountPickerEnabled(config)) return []; const storedPoolAccounts = new Set( (config.codexAccounts ?? []) .filter(account => !account.isMain) .map(account => account.id), ); - return Object.entries(config.codexAccountNamespaces ?? {}) + return codexAccountNamespaceEntries(config) .filter(([, accountId]) => isMainCodexAccountTarget(accountId) || storedPoolAccounts.has(accountId) ) @@ -53,7 +58,7 @@ export function trustedAccountBoundNativeCatalogSlug(entry: RawEntry): string | } export function accountBoundNativeModelSlugs( - config: Pick, + config: Pick, nativeSlugs: Iterable, ): string[] { const natives = [...nativeSlugs]; diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index 03b0e17c9..83f0f4297 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -13,7 +13,7 @@ import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJa import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { getProviderRegistryEntry } from "../../providers/registry"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; -import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; +import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; @@ -282,6 +282,8 @@ export const slugAliasCollisionWarnings = new Set(); export const comboMasqueradeCollisionWarnings = new Set(); +export const comboUnrestorableShadowWarnings = new Set(); + export const accountSelectorShadowCollisionWarnings = new Set(); let lastWarningReconciledGeneration = 0; @@ -291,11 +293,13 @@ export function reconcileCatalogWarningMemos(generation: number): number { + comboCatalogWarningSignatures.size + slugAliasCollisionWarnings.size + comboMasqueradeCollisionWarnings.size + + comboUnrestorableShadowWarnings.size + accountSelectorShadowCollisionWarnings.size; openAiApiCollisionWarnings.clear(); comboCatalogWarningSignatures.clear(); slugAliasCollisionWarnings.clear(); comboMasqueradeCollisionWarnings.clear(); + comboUnrestorableShadowWarnings.clear(); accountSelectorShadowCollisionWarnings.clear(); lastWarningReconciledGeneration = generation; return removed; @@ -309,6 +313,15 @@ export function warnComboMasqueradeCollisionOnce(slug: string): void { ); } +export function warnComboUnrestorableShadowOnce(slug: string): void { + const key = slugEquivalenceKey(slug); + if (comboUnrestorableShadowWarnings.has(key)) return; + comboUnrestorableShadowWarnings.add(key); + console.warn( + `[opencodex] combo alias collision on "${safeCatalogWarningLabel(slug)}": the existing user-managed or foreign catalog row is retained because no pristine backup can restore it. Rename the combo alias to expose both models.`, + ); +} + /** Warn once when a live provider row loses its reserved slug to an account selector. */ export function warnAccountSelectorShadowedProviderOnce(slug: string): void { if (accountSelectorShadowCollisionWarnings.has(slug)) return; diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index 78afdb8b5..d80dd2f0a 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -328,6 +328,9 @@ export type CatalogSourceForGather = | "legacy-backup-fallback" | "models-cache-fallback"; catalog: ReadonlyRawCatalog; + runtimeSupport: + | Readonly<{ kind: "available"; catalog: ReadonlyRawCatalog }> + | Readonly<{ kind: "unavailable" }>; processLocal: Readonly<{ runtime: CatalogGatherProcessLocalObservation; bundledCatalog: CatalogGatherProcessLocalObservation; @@ -341,6 +344,8 @@ export type CatalogSourceForGather = }>; }>; +export type CatalogGatherPathKind = "default" | "custom"; + const UNUSED_PROCESS_LOCAL = Object.freeze({ state: "unused" as const }); function sameRuntimeIdentity( @@ -407,33 +412,58 @@ export function peekCodexRuntimeForCatalogGather( */ export function resolveCatalogSourceForGather( evidenceSession: CatalogGatherEvidenceSession, + pathKind: CatalogGatherPathKind, ): CatalogSourceForGather { const bundledMemo = bundledCatalogCache; + let runtimeSupport: + | Readonly<{ kind: "available"; catalog: ReadonlyRawCatalog }> + | Readonly<{ kind: "unavailable" }> = Object.freeze({ kind: "unavailable" }); + let processLocal: Readonly<{ + runtime: CatalogGatherProcessLocalObservation; + bundledCatalog: CatalogGatherProcessLocalObservation; + }> = { + runtime: UNUSED_PROCESS_LOCAL, + bundledCatalog: UNUSED_PROCESS_LOCAL, + }; if (bundledMemo?.value && bundledMemo.expiresAt > Date.now()) { const runtime = peekCodexRuntimeForCatalogGather(evidenceSession); if (runtime.kind === "available" && bundledMemo.key === bundledRuntimeKey(runtime.runtime)) { - return cloneAndDeepFreeze({ + runtimeSupport = Object.freeze({ kind: "available" as const, - source: "bundled-catalog-template" as const, catalog: bundledMemo.value, - processLocal: { - runtime: runtime.processLocal, - bundledCatalog: { - state: "used" as const, - epoch: bundledMemo.epoch, - valueIdentity: bundledMemo.valueIdentity, - }, - }, }); + processLocal = { + runtime: runtime.processLocal, + bundledCatalog: { + state: "used" as const, + epoch: bundledMemo.epoch, + valueIdentity: bundledMemo.valueIdentity, + }, + }; + if (pathKind === "default") { + return cloneAndDeepFreeze({ + kind: "available" as const, + source: "bundled-catalog-template" as const, + catalog: bundledMemo.value, + runtimeSupport, + processLocal, + }); + } } } - const roles = [ - "active-catalog-merge", - "hashed-backup-fallback", - "legacy-backup-fallback", - "models-cache-fallback", - ] as const; + const roles = pathKind === "default" + ? [ + "active-catalog-merge", + "hashed-backup-fallback", + "legacy-backup-fallback", + "models-cache-fallback", + ] as const + : [ + "active-catalog-merge", + "hashed-backup-fallback", + "models-cache-fallback", + ] as const; for (const role of roles) { const bytes = evidenceSession.readSource(role); if (bytes === null) continue; @@ -443,10 +473,8 @@ export function resolveCatalogSourceForGather( kind: "available" as const, source: role, catalog, - processLocal: { - runtime: UNUSED_PROCESS_LOCAL, - bundledCatalog: UNUSED_PROCESS_LOCAL, - }, + runtimeSupport, + processLocal, }); } diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 13fd33bba..bfad98647 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -35,7 +35,7 @@ import { readCatalog, readCodexCatalogPath } from "./parsing"; import type { CatalogModel, RawEntry } from "./parsing"; import { UPSTREAM_NATIVE_ENTRIES } from "./metadata"; import { loadBundledCodexCatalog } from "./bundled"; -import type { BundledCatalogDeps } from "./bundled"; +import type { BundledCatalogDeps, ReadonlyRawCatalog } from "./bundled"; import { deriveEntry } from "./sync"; import { formatClampLogLines, @@ -214,11 +214,13 @@ export function ensureUltraReasoningLevel(entry: RawEntry): void { entry.supported_reasoning_levels = levels; } -export function codexSupportedReasoningEfforts(deps: BundledCatalogDeps = {}): Set | null { - const bundled = loadBundledCodexCatalog(deps); - if (!bundled) return null; +/** Derive the installed Codex effort vocabulary from caller-observed bundled catalog bytes. */ +export function supportedCodexReasoningEffortsFromObservedCatalog( + catalog: ReadonlyRawCatalog | null, +): ReadonlySet | null { + if (!catalog) return null; const efforts = new Set(); - for (const model of bundled.models ?? []) { + for (const model of catalog.models ?? []) { if (typeof model.slug !== "string" || model.slug.includes("/")) continue; const levels = Array.isArray(model.supported_reasoning_levels) ? model.supported_reasoning_levels : []; for (const level of levels) { @@ -230,6 +232,10 @@ export function codexSupportedReasoningEfforts(deps: BundledCatalogDeps = {}): S return efforts.size > 0 ? efforts : null; } +export function codexSupportedReasoningEfforts(deps: BundledCatalogDeps = {}): ReadonlySet | null { + return supportedCodexReasoningEffortsFromObservedCatalog(loadBundledCodexCatalog(deps)); +} + export function clampedDefaultEffort(original: string, surviving: readonly string[]): string { if (surviving.length === 0) return "medium"; const ranked = [...surviving] @@ -240,7 +246,10 @@ export function clampedDefaultEffort(original: string, surviving: readonly strin return (atOrBelow.at(-1) ?? ranked[0]!).effort; } -export function clampEntryToCodexSupportedEfforts(entry: RawEntry, supported: Set | null): void { +export function clampEntryToCodexSupportedEfforts( + entry: RawEntry, + supported: ReadonlySet | null, +): void { if (!supported) return; const levels = Array.isArray(entry.supported_reasoning_levels) ? entry.supported_reasoning_levels as Array<{ effort?: string }> @@ -263,12 +272,17 @@ export function clampEntryToCodexSupportedEfforts(entry: RawEntry, supported: Se } } -export function clampCatalogModelsToCodexSupport(models: RawEntry[], deps: BundledCatalogDeps = {}): RawEntry[] { - const supported = codexSupportedReasoningEfforts(deps); - if (!supported) { - if (!deps.commandCandidates) persistEffortClamp(null, { configDir: deps.configDir }); - return models; - } +export interface ObservedCatalogEffortClamp { + readonly removedEfforts: readonly string[]; + readonly affectedModels: readonly string[]; +} + +/** Apply an already-observed runtime ladder without probing, logging, or writing diagnostics. */ +export function clampCatalogModelsToObservedCodexSupport( + models: RawEntry[], + supported: ReadonlySet | null, +): ObservedCatalogEffortClamp { + if (!supported) return { removedEfforts: [], affectedModels: [] }; const removed = new Set(); const affected: string[] = []; @@ -301,6 +315,20 @@ export function clampCatalogModelsToCodexSupport(models: RawEntry[], deps: Bundl } } + return { + removedEfforts: [...removed].sort(), + affectedModels: affected, + }; +} + +export function clampCatalogModelsToCodexSupport(models: RawEntry[], deps: BundledCatalogDeps = {}): RawEntry[] { + const supported = codexSupportedReasoningEfforts(deps); + if (!supported) { + if (!deps.commandCandidates) persistEffortClamp(null, { configDir: deps.configDir }); + return models; + } + const clamp = clampCatalogModelsToObservedCodexSupport(models, supported); + let runtimePath = "codex"; let runtimeVersion: string | null = null; if (!deps.commandCandidates) { @@ -337,12 +365,12 @@ export function clampCatalogModelsToCodexSupport(models: RawEntry[], deps: Bundl } } - if (removed.size > 0) { + if (clamp.removedEfforts.length > 0) { const diagnostic: EffortClampDiagnostic = { runtimePath, runtimeVersion, - removedEfforts: [...removed].sort(), - affectedModels: affected, + removedEfforts: [...clamp.removedEfforts], + affectedModels: [...clamp.affectedModels], }; for (const line of formatClampLogLines(diagnostic)) console.warn(line); if (!deps.commandCandidates) persistEffortClamp(diagnostic, { configDir: deps.configDir }); diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 41251cbbb..7a98d04df 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -85,6 +85,9 @@ export function isDefaultCatalogPath(path: string): boolean { return samePath(path, activeDefaultCatalogPath()); } +/** Stable nonsemantic ownership marker for rows projected from config.customModels. */ +export const CODEX_CUSTOM_MODEL_CATALOG_KIND = "custom-model-v1"; + export interface CatalogModel { id: string; provider: string; @@ -113,6 +116,8 @@ export interface CatalogModel { supportsReasoningSummaries?: boolean; /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */ capabilities?: string[]; + /** OpenCodex-only catalog ownership marker; Codex ignores the serialized extension field. */ + catalogKind?: typeof CODEX_CUSTOM_MODEL_CATALOG_KIND; } export type RawEntry = Record; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 545a387c3..24e8e5100 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -62,7 +62,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } from "../../lib/admission"; -import { JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, JAWCODE_CATALOG_AUGMENT_PROVIDERS, catalogModelSlug, shouldExposeRoutedModel } from "./parsing"; import type { CatalogModel } from "./parsing"; import { disabledNativeSlugs, hasComboTargets, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; @@ -87,9 +87,16 @@ export interface CatalogGatherProviderAuthOutcome { readonly state: OAuthActiveTokenObservation["kind"]; } +export interface CatalogGatherProviderModelOutcome { + readonly provider: string; + readonly state: "authoritative" | "degraded"; +} + export interface GatherRoutedModelsOptions { comboOmissions?: ComboCatalogOmission[]; providerAuthOutcomes?: CatalogGatherProviderAuthOutcome[]; + /** Flight-local authority of each provider's returned model rows. */ + providerModelOutcomes?: CatalogGatherProviderModelOutcome[]; /** Internal convergence sink for the immutable policy that produced the returned rows. */ discoveryPolicySnapshots?: CatalogProviderDiscoveryPolicySnapshot[]; } @@ -98,9 +105,15 @@ interface GatherFlightResult { models: CatalogModel[]; comboOmissions: ComboCatalogOmission[]; providerAuthOutcomes: readonly CatalogGatherProviderAuthOutcome[]; + providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[]; discoveryPolicySnapshots: readonly CatalogProviderDiscoveryPolicySnapshot[]; } +interface ProviderModelsResult { + readonly models: CatalogModel[]; + readonly outcome: CatalogGatherProviderModelOutcome; +} + interface ModelsAuthResolution { readonly apiKey: string | undefined; readonly observed: boolean; @@ -827,9 +840,13 @@ async function fetchProviderModelsWithAuth( ttlMs: number, contextCap: number | undefined, resolveAuth: ModelsAuthResolver, -): Promise { +): Promise { const { name, provider: prov, discovery, request } = captured; - if (prov.authMode === "forward") return []; // ChatGPT backend has no /models + const observed = ( + models: CatalogModel[], + state: CatalogGatherProviderModelOutcome["state"], + ): ProviderModelsResult => ({ models, outcome: { provider: name, state } }); + if (prov.authMode === "forward") return observed([], "authoritative"); // ChatGPT backend has no /models const seedVertexDefault = prov.adapter === "google" && prov.googleMode === "vertex" && (prov.models?.length ?? 0) === 0 @@ -844,7 +861,7 @@ async function fetchProviderModelsWithAuth( // discovery failure left by an older live configuration even when the account is logged out. if (prov.liveModels === false) { clearProviderDiscoveryStatus(name); - return configured; + return observed(configured, "authoritative"); } const auth: ModelsAuthResolution = captured.observedAuth ?? (resolveAuth.kind === "refreshing" ? { apiKey: await resolveModelsAuthToken(name, prov), observed: false } @@ -868,16 +885,21 @@ async function fetchProviderModelsWithAuth( : models ); if (prov.adapter === "cursor") { - if (!apiKey) return configured; + if (!apiKey) return observed(configured, "degraded"); // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort // suffix) but filter the static seed to the bases the account actually has — so models not on the // plan (e.g. claude-fable-5) drop out instead of failing ERROR_BAD_MODEL_NAME. Fall back to the seed. const cachedCursor = getFreshCached(name, ttlMs); - if (cachedCursor) return applyConfigHintsToCachedModels(name, prov, cachedCursor); + if (cachedCursor) { + return observed(applyConfigHintsToCachedModels(name, prov, cachedCursor), "authoritative"); + } if (isModelsFetchCoolingDown(name)) { const cooling = getStaleCached(name); - return cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured; + return observed( + cooling ? applyConfigHintsToCachedModels(name, prov, cooling) : configured, + "degraded", + ); } const liveResult = await fetchCursorUsableModels({ apiKey, baseUrl: prov.baseUrl }); if (liveResult.ok) { @@ -886,7 +908,7 @@ async function fetchProviderModelsWithAuth( // Count what discovery actually returned, not the configured rows we fall back to. markProviderDiscoveryOk(name, liveResult.models.length); setCached(name, result); - return result; + return observed(result, "authoritative"); } markModelsFetchFailure(name); markProviderDiscoveryFailed(name, { reason: "provider" }); @@ -894,21 +916,34 @@ async function fetchProviderModelsWithAuth( `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`, ); const staleCursor = getStaleCached(name); - return staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured; + return observed( + staleCursor ? applyConfigHintsToCachedModels(name, prov, staleCursor) : configured, + "degraded", + ); } if (prov.authMode === "oauth" && !apiKey) { // No usable token (logged out, or account marked needsReauth). Still surface the // configured static catalog so the GUI Models tab / rail counts are not empty — // matching Cursor's !apiKey → configured degradation and fetch-failure fallback. - return configured; + return observed(configured, "degraded"); } const fresh = getFreshCached(name, ttlMs); - if (fresh) return withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)); // dedups Codex's frequent /v1/models polling within the TTL + if (fresh) { + return observed( + withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, fresh, contextCap)), + "authoritative", + ); // dedups Codex's frequent /v1/models polling within the TTL + } if (isModelsFetchCoolingDown(name)) { // A recently-failed provider (unreachable API, missing proxy, bad key) must not re-pay the // fetch timeout on every catalog poll — the dashboard polls this path per page load. const stale = getStaleCached(name); - return stale ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) : failedDiscoveryConfigured; + return observed( + stale + ? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap)) + : failedDiscoveryConfigured, + "degraded", + ); } const url = request.url; const headers = materializeCapturedHeaders(request, apiKey); @@ -946,7 +981,7 @@ async function fetchProviderModelsWithAuth( `[opencodex] Provider model discovery for "${name}" ${redirectError} [urlClass=${urlClass}, fallback=${fallback}].`, ); } - return models; + return observed(models, "degraded"); } if (!res.ok) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status }); @@ -955,7 +990,7 @@ async function fetchProviderModelsWithAuth( `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`, ); } - return models; + return observed(models, "degraded"); } const contentType = ( @@ -974,7 +1009,7 @@ async function fetchProviderModelsWithAuth( `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, ); } - return models; + return observed(models, "degraded"); } const extracted = extractProviderModelItems(bounded.value, discovery); if (!extracted.ok) { @@ -990,7 +1025,7 @@ async function fetchProviderModelsWithAuth( `[opencodex] Provider model discovery for "${name}" ${diagnostic[extracted.reason]} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`, ); } - return models; + return observed(models, "degraded"); } const items = extracted.items; const live = items.map(m => { @@ -1036,7 +1071,7 @@ async function fetchProviderModelsWithAuth( } markProviderDiscoveryOk(name, liveModelCount); setCached(name, live); - return live; + return observed(live, "authoritative"); } catch (error) { if (error instanceof ProviderOutboundPolicyError) { const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" }); @@ -1045,7 +1080,7 @@ async function fetchProviderModelsWithAuth( `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${error.message} [urlClass=${urlClass}, fallback=${fallback}].`, ); } - return models; + return observed(models, "degraded"); } const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "network" }); if (shouldLog) { @@ -1053,7 +1088,7 @@ async function fetchProviderModelsWithAuth( `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`, ); } - return models; + return observed(models, "degraded"); } } @@ -1064,7 +1099,12 @@ export async function fetchProviderModels( contextCap?: number, ): Promise { const captured = captureProviderGather(name, prov, refreshingModelsAuthResolver); - return fetchProviderModelsWithAuth(captured, ttlMs, contextCap, refreshingModelsAuthResolver); + return (await fetchProviderModelsWithAuth( + captured, + ttlMs, + contextCap, + refreshingModelsAuthResolver, + )).models; } export function shouldExposeProviderModel(providerName: string, modelId: string): boolean { @@ -1176,6 +1216,7 @@ async function gatherRoutedModelsWithAuth( models, comboOmissions, providerAuthOutcomes, + providerModelOutcomes, discoveryPolicySnapshots, } = await entry.promise; if (options?.comboOmissions) { @@ -1186,6 +1227,10 @@ async function gatherRoutedModelsWithAuth( options.providerAuthOutcomes.length = 0; options.providerAuthOutcomes.push(...providerAuthOutcomes); } + if (options?.providerModelOutcomes) { + options.providerModelOutcomes.length = 0; + options.providerModelOutcomes.push(...providerModelOutcomes); + } if (options?.discoveryPolicySnapshots) { options.discoveryPolicySnapshots.length = 0; options.discoveryPolicySnapshots.push(...discoveryPolicySnapshots); @@ -1209,7 +1254,7 @@ async function gatherRoutedModelsUncached( // vision-sidecar model advertised text-only, blocking image attachments app-side). // Enrich a CLONE: hydrated defaults must never leak into the persisted config. const activeProviders = capture.providers; - const lists = await Promise.all( + const providerResults = await Promise.all( activeProviders.map(provider => fetchProviderModelsWithAuth( provider, ttlMs, @@ -1217,6 +1262,7 @@ async function gatherRoutedModelsUncached( resolveAuth, )), ); + const lists = providerResults.map(result => result.models); const apiAugmented = augmentRoutedModelsWithCapturedOpenAiApiRows( lists.flat(), config, @@ -1297,6 +1343,7 @@ async function gatherRoutedModelsUncached( const base: CatalogModel = { id: cm.modelId, provider: cm.provider, + catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, // Display-only label: never feeds routing (customModels are keyed by routedSlug below). ...(cm.displayName ? { displayName: cm.displayName } : {}), ...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}), @@ -1339,10 +1386,18 @@ async function gatherRoutedModelsUncached( // Custom rows override discovered rows that encode to the same Codex-facing slug. const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id))); const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id))); + const providerModelOutcomes = providerResults.map(result => ( + result.outcome.provider === OPENAI_API_PROVIDER_ID + && capture.openAiApiPolicy.state === "captured" + && capture.openAiApiPolicy.models !== undefined + ? { provider: result.outcome.provider, state: "authoritative" as const } + : result.outcome + )); return { models: [...deduped, ...customModels], comboOmissions: localOmissions, providerAuthOutcomes: localProviderAuthOutcomes, + providerModelOutcomes, discoveryPolicySnapshots: capture.discoveryPolicySnapshots, }; } diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 8ad5becfa..30dbd1517 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -13,7 +13,7 @@ import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, mo import { getJawcodeModelMetadata, getJawcodeModelMetadataCaseInsensitive, listJawcodeModelMetadata, resolveJawcodeProvider } from "../../generated/jawcode-model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; -import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; +import { routedSlug, slugEquals, slugEquivalenceKey, slugsEquivalent } from "../../providers/slug-codec"; import { identifyRoutedModel } from "../../adapters/identity"; import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery"; import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; @@ -30,9 +30,9 @@ import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; -import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; +import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { bundledCatalogCacheState, loadBundledCodexCatalog, @@ -40,8 +40,14 @@ import { } from "./bundled"; import { isMultiAgentV2Enabled } from "../features"; import { applyCatalogModelMetadata, applyReasoningLevels, catalogEntryEfforts, clampCatalogModelsToCodexSupport, ensureGpt56ReasoningLevels, ensureUltraReasoningLevel, isGpt56NativeSlug } from "./effort"; -import { clearGatherRoutedModelsInflight, filterCatalogVisibleModels, gatherRoutedModels, lastDropWarnSignature } from "./provider-fetch"; -import { accountSelectorShadowCollisionWarnings, clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnAccountSelectorShadowedProviderOnce, warnComboMasqueradeCollisionOnce } from "./aggregation"; +import { + clearGatherRoutedModelsInflight, + filterCatalogVisibleModels, + gatherRoutedModels, + lastDropWarnSignature, + type CatalogGatherProviderModelOutcome, +} from "./provider-fetch"; +import { accountSelectorShadowCollisionWarnings, clearLastComboCatalogOmissions, comboCatalogWarningSignatures, comboMasqueradeCollisionWarnings, comboUnrestorableShadowWarnings, exactComboCatalogSlugs, openAiApiCollisionWarnings, resolveSlugAliasCollisions, slugAliasCollisionWarnings, warnAccountSelectorShadowedProviderOnce, warnComboMasqueradeCollisionOnce, warnComboUnrestorableShadowOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; import { withCatalogWriteSerialization, @@ -197,7 +203,16 @@ export function isExactComboCatalogModel( model: CatalogModel | undefined, exactComboSlugs: ReadonlySet, ): boolean { - return model !== undefined && exactComboSlugs.has(catalogModelSlug(model)); + return model?.provider === COMBO_NAMESPACE && exactComboSlugs.has(catalogModelSlug(model)); +} + +function isExactComboCatalogEntry( + entry: RawEntry, + exactComboSlugs: ReadonlySet, +): boolean { + return entry.owned_by === COMBO_NAMESPACE + && typeof entry.slug === "string" + && exactComboSlugs.has(entry.slug); } /** @@ -268,6 +283,7 @@ export function deriveEntry( normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true); if (model) applyJawcodeCatalogMetadata(e, model.provider, model.id, model.contextCap); applyCatalogModelMetadata(e, model); + if (model?.catalogKind) e.opencodex_catalog_kind = model.catalogKind; } else { applyNativeOpenAiContextOverride(e); if (isGpt56NativeSlug(slug)) ensureGpt56ReasoningLevels(e); @@ -305,6 +321,7 @@ export function deriveEntry( } if (model && isRouted) applyJawcodeCatalogMetadata(entry, model.provider, model.id, model.contextCap); applyCatalogModelMetadata(entry, model); + if (model?.catalogKind) entry.opencodex_catalog_kind = model.catalogKind; if (!isRouted) applyNativeOpenAiContextOverride(entry); return ensureStrictCatalogFields(normalizeServiceTiers(entry), { preserveExactInputModalities: preserveExact, @@ -312,6 +329,19 @@ export function deriveEntry( }); } +export interface ObservedCatalogEntryBuildInput { + readonly template: RawEntry | null; + readonly gptSlugs: readonly string[]; + readonly goModels: readonly CatalogModel[]; + readonly featured?: readonly string[]; + readonly wsEnabled: boolean; + readonly multiAgentMode: MultiAgentMode; + readonly exactComboSlugs: ReadonlySet; + readonly accountSelectors: readonly string[]; + readonly multiAgentV2Enabled: boolean; +} + +/** Build entries with the process-observed Codex feature state. */ export function buildCatalogEntries( template: RawEntry | null, gptSlugs: string[], @@ -322,6 +352,31 @@ export function buildCatalogEntries( exactComboSlugs: ReadonlySet = new Set(), accountSelectors: readonly string[] = [], ): RawEntry[] { + return buildCatalogEntriesFromObservedState({ + template, + gptSlugs, + goModels, + featured, + wsEnabled, + multiAgentMode, + exactComboSlugs, + accountSelectors, + multiAgentV2Enabled: isMultiAgentV2Enabled(), + }); +} + +/** Build entries solely from caller-observed inputs, with no feature-state filesystem read. */ +export function buildCatalogEntriesFromObservedState({ + template, + gptSlugs, + goModels, + featured, + wsEnabled, + multiAgentMode, + exactComboSlugs, + accountSelectors, + multiAgentV2Enabled, +}: 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 // ARRAY order is discarded — so "featuring" a model = giving it the LOWEST priority (0..N-1) so @@ -330,7 +385,7 @@ export function buildCatalogEntries( const priorityStride = Math.max(accountSelectors.length, 1); const out: RawEntry[] = []; const nativeEntries: RawEntry[] = []; - const collisionSkipped = resolveSlugAliasCollisions(goModels); + const collisionSkipped = resolveSlugAliasCollisions([...goModels]); const comboPublicSlugs = new Set(goModels .filter(model => model.provider === COMBO_NAMESPACE) .map(catalogModelSlug)); @@ -397,7 +452,7 @@ export function buildCatalogEntries( delete entry.prefer_websockets; } } - return applyMultiAgentMode(out, multiAgentMode, isMultiAgentV2Enabled()); + return applyMultiAgentMode(out, multiAgentMode, multiAgentV2Enabled); } export function resetCatalogRuntimeStateForTests(): void { @@ -407,6 +462,7 @@ export function resetCatalogRuntimeStateForTests(): void { comboCatalogWarningSignatures.clear(); slugAliasCollisionWarnings.clear(); comboMasqueradeCollisionWarnings.clear(); + comboUnrestorableShadowWarnings.clear(); accountSelectorShadowCollisionWarnings.clear(); clearLastComboCatalogOmissions(); clearModelCache(); @@ -442,60 +498,189 @@ function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean { return slug.includes("/") && desc.startsWith("Routed via opencodex → "); } -export function mergeCatalogEntriesForSync( - catalogModels: RawEntry[], - routedEntries: RawEntry[], - baseline: Map, - featured: string[], - wsEnabled: boolean, - goIds: Set = new Set(), - template: RawEntry | null = null, - disabledModels: ReadonlySet = new Set(), - gatheredProviderNames: Set = new Set(routedEntries.flatMap(entry => { - const slug = typeof entry.slug === "string" ? entry.slug : ""; +export interface ObservedCatalogMergePolicy { + /** Required observed/fixed set; the core merge never consults ambient catalog state. */ + readonly nativeBackfillSlugs: readonly string[]; + /** Whether unsupported OpenAI-family bare rows survive the merge. */ + readonly unsupportedNativeEntries: "preserve" | "drop"; + /** Whether merge-policy collision/preservation warnings belong to this caller's flow. */ + readonly warningPolicy: "emit" | "suppress"; +} + +/** Content policy shared by every writer of the canonical Codex model catalog. */ +export const CANONICAL_NATIVE_CATALOG_CONTENT_POLICY: Readonly< + Pick +> = Object.freeze({ + nativeBackfillSlugs: Object.freeze([...NATIVE_OPENAI_MODELS]), + unsupportedNativeEntries: "drop", +}); + +export interface ObservedCatalogMergeInput { + readonly catalogModels: readonly RawEntry[]; + readonly baselineCatalogModels: readonly RawEntry[]; + readonly routedEntries: readonly RawEntry[]; + readonly baseline: ReadonlyMap; + readonly featured: readonly string[]; + readonly wsEnabled: boolean; + readonly template: RawEntry | null; + readonly disabledModels: ReadonlySet; + readonly selectedModelsByProvider: ReadonlyMap>; + readonly gatheredProviderNames: ReadonlySet; + readonly degradedProviderNames: ReadonlySet; + readonly multiAgentMode: MultiAgentMode; + readonly multiAgentV2Enabled: boolean; + readonly exactComboSlugs: ReadonlySet; + readonly hasPhysicalComboProvider: boolean; + readonly includeNativeOpenAi: boolean; + readonly accountBoundEntries: readonly RawEntry[]; + readonly policy: ObservedCatalogMergePolicy; +} + +/** + * Deterministically merge one fully observed catalog state. + * + * Every non-catalog input is explicit so evidence-bound convergence cannot + * accidentally fall back to process-ambient catalog discovery or merge-policy warnings. + */ +export function mergeCatalogEntriesFromObservedState({ + catalogModels, + baselineCatalogModels, + routedEntries, + baseline, + featured, + wsEnabled, + template, + disabledModels, + selectedModelsByProvider, + gatheredProviderNames, + degradedProviderNames, + multiAgentMode, + multiAgentV2Enabled, + exactComboSlugs, + hasPhysicalComboProvider, + includeNativeOpenAi, + accountBoundEntries, + policy, +}: ObservedCatalogMergeInput): RawEntry[] { + // Raw catalog rows contain nested arrays/objects that normalization mutates. Detach every row at + // the observed-core boundary so callers can safely retain evidence objects or repeat the merge. + const detachedCatalogModels = catalogModels.map(entry => structuredClone(entry) as RawEntry); + const detachedBaselineCatalogModels = baselineCatalogModels + .map(entry => structuredClone(entry) as RawEntry); + const detachedRoutedEntries = routedEntries.map(entry => structuredClone(entry) as RawEntry); + const detachedAccountBoundEntries = accountBoundEntries + .map(entry => structuredClone(entry) as RawEntry); + const disabledModelKeys = new Set([...disabledModels].map(slugEquivalenceKey)); + const selectedModelKeysByProvider = new Map([...selectedModelsByProvider].map(([provider, models]) => ( + [provider, new Set([...models].map(model => slugEquivalenceKey(routedSlug(provider, model))))] as const + ))); + const freshAccountKeys = new Set(detachedAccountBoundEntries.flatMap(entry => ( + typeof entry.slug === "string" ? [slugEquivalenceKey(entry.slug)] : [] + ))); + const wouldSurviveUnreplaced = (entry: RawEntry): boolean => { + if (entry.owned_by === COMBO_NAMESPACE + || trustedAccountBoundNativeCatalogSlug(entry) !== undefined + || entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND + || isOcxAuthoredRoutedEntry(entry) + || typeof entry.slug !== "string") return false; + const slug = entry.slug; + if (!slug.includes("/")) { + if (!includeNativeOpenAi || policy.nativeBackfillSlugs.includes(slug)) return false; + return policy.unsupportedNativeEntries === "preserve" || !isUnsupportedOpenAiNativeSlug(slug); + } + if (isRoutedModelCompatibilityExcluded(slug)) return false; + if (!hasPhysicalComboProvider && slug.startsWith(`${COMBO_NAMESPACE}/`)) return false; + const key = slugEquivalenceKey(slug); + if (freshAccountKeys.has(key)) return false; + if (disabledModelKeys.has(key)) return false; const slash = slug.indexOf("/"); - return slash > 0 ? [slug.slice(0, slash)] : []; - })), - multiAgentMode: MultiAgentMode = "default", - exactComboSlugs: ReadonlySet = new Set(), - hasPhysicalComboProvider = false, - includeNativeOpenAi = true, - accountBoundEntries: readonly RawEntry[] = [], -): RawEntry[] { + const provider = slug.slice(0, slash); + const selected = selectedModelKeysByProvider.get(provider); + if (selected !== undefined && !selected.has(key)) return false; + return !gatheredProviderNames.has(provider) || degradedProviderNames.has(provider); + }; + const validRoutedEntries = detachedRoutedEntries.filter(entry => { + return !isExactComboCatalogEntry(entry, exactComboSlugs) + || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0); + }); + const restorableCatalogKeys = new Set(detachedBaselineCatalogModels.flatMap(entry => ( + wouldSurviveUnreplaced(entry) && typeof entry.slug === "string" + ? [slugEquivalenceKey(entry.slug)] + : [] + ))); + const unrestorableCatalogKeys = new Set(detachedCatalogModels.flatMap(entry => { + if (!wouldSurviveUnreplaced(entry) || typeof entry.slug !== "string") return []; + const key = slugEquivalenceKey(entry.slug); + return restorableCatalogKeys.has(key) ? [] : [key]; + })); + const admittedRoutedEntries = validRoutedEntries.filter(entry => { + if (!isExactComboCatalogEntry(entry, exactComboSlugs)) return true; + const slug = entry.slug as string; + const key = slugEquivalenceKey(slug); + if (!unrestorableCatalogKeys.has(key)) return true; + if (policy.warningPolicy === "emit") warnComboUnrestorableShadowOnce(slug); + return false; + }); const rank = new Map(featured.map((slug, i) => [slug, i] as const)); + const freshEquivalentKeys = new Set(admittedRoutedEntries.flatMap(entry => ( + typeof entry.slug === "string" ? [slugEquivalenceKey(entry.slug)] : [] + ))); + const freshEquivalent = (slug: string): boolean => ( + freshEquivalentKeys.has(slugEquivalenceKey(slug)) + ); + const freshBareComboAliases = new Set(admittedRoutedEntries.flatMap(entry => ( + typeof entry.slug === "string" + && !entry.slug.includes("/") + && entry.owned_by === COMBO_NAMESPACE + ? [entry.slug] + : [] + ))); + const staleComboKeys = new Set(detachedCatalogModels.flatMap(entry => ( + typeof entry.slug === "string" + && entry.owned_by === COMBO_NAMESPACE + && !freshEquivalent(entry.slug) + ? [slugEquivalenceKey(entry.slug)] + : [] + ))); + const currentNonComboKeys = new Set(detachedCatalogModels.flatMap(entry => ( + entry.owned_by !== COMBO_NAMESPACE && typeof entry.slug === "string" + ? [slugEquivalenceKey(entry.slug)] + : [] + ))); + const restoredComboShadows = detachedBaselineCatalogModels.filter(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + if (!slug || entry.owned_by === COMBO_NAMESPACE) return false; + const key = slugEquivalenceKey(slug); + 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 native = includeNativeOpenAi - ? catalogModels + ? catalogModelsForMerge .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.owned_by !== COMBO_NAMESPACE - && !goIds.has(m.slug as string) - && !isUnsupportedOpenAiNativeSlug(m.slug as string)) + && !freshBareComboAliases.has(m.slug as string) + && (policy.unsupportedNativeEntries === "preserve" + || !isUnsupportedOpenAiNativeSlug(m.slug as string))) .map(m => { const slug = m.slug as string; - // Featured models rank first (rank order); non-featured natives are pushed below the featured - // block when any model is featured, else keep their pristine baseline priority. - const baselinePriority = baseline.get(slug) ?? (m.priority as number); - const priority = rank.has(slug) - ? rank.get(slug)! - : featured.length > 0 - ? Math.max(typeof baselinePriority === "number" ? baselinePriority : 9, featured.length + 100) - : baselinePriority; // Fallback-quality entries (ocx synthesis / codex-rs model_info fallback: display_name // stamped with the bare slug) are upgraded to the pinned upstream snapshot entry so a // previously synthesized ladder (e.g. luna advertising ultra) self-heals on sync. A // genuine catalog entry (real display name) is preserved untouched. if (shouldUpgradeToUpstreamEntry(m)) { const upstream = upstreamNativeEntry(slug)!; - const upgradePriority = rank.has(slug) - ? rank.get(slug)! - : featured.length > 0 - ? Math.max(typeof upstream.priority === "number" ? upstream.priority : 9, featured.length + 100) - : typeof upstream.priority === "number" ? upstream.priority : priority; const finished = finishUpstreamNativeEntry(upstream, 9); - finished.priority = upgradePriority; + finished.priority = nativePriority(slug, upstream.priority); return finished; } - const preserved = normalizeServiceTiers({ ...m, priority }); + const preserved = normalizeServiceTiers({ ...m, priority: nativePriority(slug, m.priority) }); // Older natives kept from disk still need the mock top tiers (max + ultra always // for subagent max spawns; wire-clamped to the model's real top rung). if (!isGpt56NativeSlug(slug)) ensureUltraReasoningLevel(preserved); @@ -508,22 +693,24 @@ export function mergeCatalogEntriesForSync( // Skip when no enabled canonical openai provider exists (#636) — bare gpt-* would 404. const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : [])); if (includeNativeOpenAi) { - for (const slug of nativeOpenAiSlugs()) { - if (nativeSlugs.has(slug)) continue; - nativeSlugs.add(slug); - const priority = rank.has(slug) - ? rank.get(slug)! - : featured.length > 0 - ? featured.length + 100 - : 9; - native.push(deriveEntry(template ? JSON.parse(JSON.stringify(template)) : null, slug, "OpenAI native model (Codex OAuth passthrough).", priority)); - } + for (const slug of policy.nativeBackfillSlugs) { + if (nativeSlugs.has(slug)) continue; + nativeSlugs.add(slug); + const entry = deriveEntry( + template ? JSON.parse(JSON.stringify(template)) : null, + slug, + "OpenAI native model (Codex OAuth passthrough).", + nativePriority(slug, upstreamNativeEntry(slug)?.priority), + ); + entry.priority = nativePriority(slug, upstreamNativeEntry(slug)?.priority); + native.push(entry); + } } const nativeBySlug = new Map(native.flatMap(entry => typeof entry.slug === "string" ? [[entry.slug, entry] as const] : [] )); - const alignedAccountBoundEntries = accountBoundEntries.map(entry => { + const alignedAccountBoundEntries = detachedAccountBoundEntries.map(entry => { const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); const source = nativeSlug === undefined ? undefined : nativeBySlug.get(nativeSlug); if (!source) return entry; @@ -537,36 +724,39 @@ export function mergeCatalogEntriesForSync( }); const freshSlugs = new Set( - routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []), + admittedRoutedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []), ); - let finalRoutedEntries = routedEntries; - const existingRoutedEntries = catalogModels.filter(m => + const existingRoutedEntries = catalogModelsForMerge.filter(m => typeof m.slug === "string" && m.slug.includes("/") && trustedAccountBoundNativeCatalogSlug(m) === undefined ); - const preservingExistingRouted = routedEntries.length === 0 - && existingRoutedEntries.length > 0; - if (preservingExistingRouted) { - // #855: transient-fetch protection keeps existing rows, but rows OpenCodex - // itself authored for a provider that is no longer configured are ghosts, - // not protected foreign entries. - finalRoutedEntries = existingRoutedEntries.filter(m => { - const provider = (m.slug as string).slice(0, (m.slug as string).indexOf("/")); - return !(isOcxAuthoredRoutedEntry(m) && !gatheredProviderNames.has(provider)); - }); - } else { - const preservedForeignRouted = catalogModels.filter(m => { - if (typeof m.slug !== "string" || !m.slug.includes("/")) return false; - if (trustedAccountBoundNativeCatalogSlug(m) !== undefined) return false; - const provider = m.slug.slice(0, m.slug.indexOf("/")); - if (gatheredProviderNames.has(provider) || freshSlugs.has(m.slug)) return false; - // #855: an OpenCodex-authored row whose provider was deleted is a ghost; - // only genuinely foreign rows (Cursor, user tooling) are preserved. - return !isOcxAuthoredRoutedEntry(m); - }); - finalRoutedEntries = [...routedEntries, ...preservedForeignRouted]; - } + const preservedRoutedEntries = existingRoutedEntries.filter(entry => { + const slug = entry.slug as string; + if (freshEquivalent(slug)) return false; + // Current custom rows are always regenerated from config, even while provider discovery is + // degraded. A marked row absent from the fresh projection is therefore an intentional delete. + if (entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND) return false; + const provider = slug.slice(0, slug.indexOf("/")); + if (gatheredProviderNames.has(provider)) { + // A provider-local degraded observation preserves only that namespace. Authoritative empty + // catalogs and successful removals still delete stale rows even when another provider fails. + return degradedProviderNames.has(provider); + } + // Deleted/disabled providers cannot retain OpenCodex-authored ghosts. Foreign catalog rows + // remain outside provider ownership and survive unless a fresh row replaces their exact slug. + return !isOcxAuthoredRoutedEntry(entry); + }); + let finalRoutedEntries = [...admittedRoutedEntries, ...preservedRoutedEntries]; + finalRoutedEntries = finalRoutedEntries.filter(entry => { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + if (!slug.includes("/")) return true; + if (disabledModelKeys.has(slugEquivalenceKey(slug))) return false; + const slash = slug.indexOf("/"); + const provider = slug.slice(0, slash); + const selected = selectedModelKeysByProvider.get(provider); + return selected === undefined || selected.has(slugEquivalenceKey(slug)); + }); if (!hasPhysicalComboProvider) { finalRoutedEntries = finalRoutedEntries.filter(entry => { const slug = typeof entry.slug === "string" ? entry.slug : ""; @@ -575,8 +765,7 @@ export function mergeCatalogEntriesForSync( }); } finalRoutedEntries = finalRoutedEntries.filter(entry => { - const slug = typeof entry.slug === "string" ? entry.slug : ""; - return !exactComboSlugs.has(slug) + return !isExactComboCatalogEntry(entry, exactComboSlugs) || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0); }); // Reapply final catalog policy to rows preserved from disk. Those rows bypass @@ -589,21 +778,30 @@ export function mergeCatalogEntriesForSync( )); finalRoutedEntries = finalRoutedEntries.filter(entry => { if (typeof entry.slug !== "string" || !accountBoundSlugs.has(entry.slug)) return true; - if (freshSlugs.has(entry.slug)) warnAccountSelectorShadowedProviderOnce(entry.slug); + if (freshSlugs.has(entry.slug) && policy.warningPolicy === "emit") { + warnAccountSelectorShadowedProviderOnce(entry.slug); + } return false; }); - if (preservingExistingRouted) { - console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`); + const finalRoutedEntrySet = new Set(finalRoutedEntries); + const degradedPreservedCount = preservedRoutedEntries.filter(entry => { + if (!finalRoutedEntrySet.has(entry)) return false; + const slug = entry.slug as string; + const provider = slug.slice(0, slug.indexOf("/")); + return gatheredProviderNames.has(provider) && degradedProviderNames.has(provider); + }).length; + if (degradedPreservedCount > 0 && policy.warningPolicy === "emit") { + console.warn(`[opencodex] catalog sync: provider discovery degraded; preserving ${degradedPreservedCount} existing routed entr${degradedPreservedCount === 1 ? "y" : "ies"} on disk.`); } const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries]; const mergedEntries = [...native, ...managedEntries].map(m => { const normalized = normalizeServiceTiers(m); applyNativeOpenAiContextOverride(normalized); - const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug); + const exactCombo = isExactComboCatalogEntry(m, exactComboSlugs); const e = ensureStrictCatalogFields(normalized, { preserveExactInputModalities: exactCombo, - isRouted: finalRoutedEntries.includes(m), + isRouted: finalRoutedEntrySet.has(m), }); // Mock-max universality (260709): preserved routed entries from disk may predate // the max rung — ensure it here so subagent max spawns validate on every @@ -629,11 +827,76 @@ export function mergeCatalogEntriesForSync( // Native enable/disable runs as the LAST pass so the upstream-upgrade branch above can never // clobber a hide flag back to list. Bare ids disable every account clone; qualified ids disable // only their generated account row. - return applyMultiAgentMode( + const versionedEntries = applyMultiAgentMode( applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0), multiAgentMode, - isMultiAgentV2Enabled(), + multiAgentV2Enabled, ); + for (const entry of versionedEntries) { + const kind = entry.opencodex_catalog_kind; + if (trustedAccountBoundNativeCatalogSlug(entry) === undefined + && kind !== CODEX_CUSTOM_MODEL_CATALOG_KIND) continue; + // Canonicalize extension-field order after every normalizer. This keeps an unchanged catalog + // byte-idempotent whether an owned row was freshly built or retained from the prior pass. + delete entry.opencodex_catalog_kind; + entry.opencodex_catalog_kind = kind; + } + return versionedEntries; +} + +/** Merge retained-sync rows using the process-observed Codex feature state. */ +export function mergeCatalogEntriesForSync( + catalogModels: RawEntry[], + routedEntries: RawEntry[], + baseline: Map, + featured: string[], + wsEnabled: boolean, + _goIds: Set = new Set(), + template: RawEntry | null = null, + disabledModels: ReadonlySet = new Set(), + gatheredProviderNames?: Set, + multiAgentMode: MultiAgentMode = "default", + exactComboSlugs: ReadonlySet = new Set(), + hasPhysicalComboProvider = false, + includeNativeOpenAi = true, + accountBoundEntries: readonly RawEntry[] = [], +): RawEntry[] { + // Retained for source compatibility with the original helper contract. Raw provider ids must + // not suppress same-named native rows; actual admitted combo entries own that decision now. + void _goIds; + const effectiveGatheredProviderNames = gatheredProviderNames ?? new Set( + routedEntries.flatMap(entry => { + // A slashed combo alias is not evidence that its public prefix is an authoritative provider + // namespace. Treating it as one would let the combo replace an unrestorable foreign row. + if (isExactComboCatalogEntry(entry, exactComboSlugs)) return []; + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const slash = slug.indexOf("/"); + return slash > 0 ? [slug.slice(0, slash)] : []; + }), + ); + return mergeCatalogEntriesFromObservedState({ + catalogModels, + baselineCatalogModels: [], + routedEntries, + baseline, + featured, + wsEnabled, + template, + disabledModels, + selectedModelsByProvider: new Map(), + gatheredProviderNames: effectiveGatheredProviderNames, + degradedProviderNames: new Set(), + multiAgentMode, + multiAgentV2Enabled: isMultiAgentV2Enabled(), + exactComboSlugs, + hasPhysicalComboProvider, + includeNativeOpenAi, + accountBoundEntries, + policy: { + ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + warningPolicy: "emit", + }, + }); } interface RetainedCatalogSyncRead { @@ -660,6 +923,7 @@ interface RetainedCatalogSyncResult { interface RetainedCatalogSyncWrite { readonly config: OcxConfig; readonly goModels: CatalogModel[]; + readonly providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[]; readonly comboOmissions: ComboCatalogOmission[]; readonly read: RetainedCatalogSyncRead; readonly permit: CatalogWritePermit; @@ -783,6 +1047,7 @@ function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null { function writeRetainedCatalogSync({ config, goModels, + providerModelOutcomes, comboOmissions, read, permit, @@ -824,26 +1089,42 @@ function writeRetainedCatalogSync({ ? visibleCodexAccountSelectors(config) : []; const wsEnabled = websocketsEnabled(config); - const goEntries = buildCatalogEntries( - template ? JSON.parse(JSON.stringify(template)) : null, - [], - orderedGoModels, + const multiAgentV2Enabled = isMultiAgentV2Enabled(); + const goEntries = buildCatalogEntriesFromObservedState({ + template: template ? JSON.parse(JSON.stringify(template)) : null, + gptSlugs: [], + goModels: orderedGoModels, featured, wsEnabled, multiAgentMode, exactComboSlugs, accountSelectors, - ); + multiAgentV2Enabled, + }); // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row. + const baselineCatalog = readCatalogBackup(catalogPath); const baseline = readNativeBaseline(catalogPath); - const goIds = new Set(enabledGo.map(m => m.id)); const gatheredProviderNames = new Set( Object.entries(config.providers ?? {}) .filter(([, prov]) => prov.disabled !== true) .map(([name]) => name), ); + const degradedProviderNames = new Set( + providerModelOutcomes + .filter(outcome => outcome.state === "degraded") + .map(outcome => outcome.provider), + ); + const selectedModelsByProvider = new Map>( + Object.entries(config.providers ?? {}).flatMap(([name, provider]) => ( + provider.disabled !== true + && Array.isArray(provider.selectedModels) + && provider.selectedModels.length > 0 + ? [[name, new Set(provider.selectedModels)] as const] + : [] + )), + ); // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a // native template can never leak supports_websockets while the flag is off. @@ -851,33 +1132,41 @@ function writeRetainedCatalogSync({ // bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no // providers are configured yet (fresh install / catalog bootstrap tests). const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0 - ? buildCatalogEntries( - template ? JSON.parse(JSON.stringify(template)) : null, - NATIVE_OPENAI_MODELS, - [], + ? buildCatalogEntriesFromObservedState({ + template: template ? JSON.parse(JSON.stringify(template)) : null, + gptSlugs: NATIVE_OPENAI_MODELS, + goModels: [], featured, wsEnabled, multiAgentMode, exactComboSlugs, accountSelectors, - ).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) + multiAgentV2Enabled, + }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) : []; - catalog.models = mergeCatalogEntriesForSync( - catalogModelsForMerge, - goEntries, + catalog.models = mergeCatalogEntriesFromObservedState({ + catalogModels: catalogModelsForMerge, + baselineCatalogModels: baselineCatalog?.models ?? [], + routedEntries: goEntries, baseline, featured, wsEnabled, - goIds, template, - new Set(config.disabledModels ?? []), + disabledModels: new Set(config.disabledModels ?? []), + selectedModelsByProvider, gatheredProviderNames, + degradedProviderNames, multiAgentMode, + multiAgentV2Enabled, exactComboSlugs, hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, - ); + policy: { + ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + warningPolicy: "emit", + }, + }); clampCatalogModelsToCodexSupport(catalog.models); replaceActiveCodexCatalog(permit, owningCodexHome, { @@ -953,6 +1242,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise { // Desired state can flip OFF during the provider await above. The catalog // evidence revalidation below cannot see that — intent lives in our config, @@ -987,6 +1280,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise { + return new Map((catalog?.models ?? []).flatMap(entry => ( + typeof entry.slug === "string" + && !entry.slug.includes("/") + && typeof entry.priority === "number" + ? [[entry.slug, entry.priority] as const] + : [] + ))); +} + interface ReadonlyRawCatalogLike { readonly models?: readonly Readonly>[]; } @@ -137,7 +163,14 @@ function processEvidence(source: CatalogSourceForGather): CatalogProcessLocalEvi function bindGatherPaths( session: CatalogFilesystemEvidenceSession, snapshot: CatalogAdmissionSnapshot, -): Readonly<{ catalog: string; cache: string; keyedBackup: string; legacyBackup?: string }> { +): Readonly<{ + catalog: string; + cache: string; + keyedBackup: string; + legacyBackup?: string; + catalogKind: CatalogGatherPathKind; + multiAgentV2Enabled: boolean; +}> { const catalog = targetPath(snapshot.targets.catalog); const cache = targetPath(snapshot.targets.cache); const keyedBackup = targetPath(snapshot.targets.catalogBackups[0]!); @@ -146,7 +179,7 @@ function bindGatherPaths( const configPath = snapshot.sourceEvidence.required["catalog-target-selection"].logicalPath; acceptCatalogGatherSourcePath(session, "catalog-target-selection", configPath); - readCatalogGatherSource(session, "catalog-target-selection"); + const configBytes = readCatalogGatherSource(session, "catalog-target-selection"); acceptCatalogGatherSourcePath(session, "active-catalog-merge", catalog); acceptCatalogGatherSourcePath(session, "hashed-backup-fallback", keyedBackup); acceptCatalogGatherSourcePath(session, "legacy-backup-fallback", legacyBackup ?? legacyCatalogBackupPath()); @@ -154,7 +187,16 @@ function bindGatherPaths( acceptCatalogGatherSourcePath(session, "runtime-selection", codexRuntimeStatePath(getConfigDir())); acceptCatalogGatherSourcePath(session, "provider-auth-selection", getAuthStorePath()); acceptCatalogGatherSourcePath(session, "native-catalog-selection", catalog); - return { catalog, cache, keyedBackup, ...(legacyBackup ? { legacyBackup } : {}) }; + return { + catalog, + cache, + keyedBackup, + ...(legacyBackup ? { legacyBackup } : {}), + catalogKind: legacyBackup ? "default" : "custom", + multiAgentV2Enabled: multiAgentV2EnabledFromConfigText( + configBytes === null ? null : Buffer.from(configBytes).toString("utf8"), + ), + }; } function prepareCatalog( @@ -162,6 +204,10 @@ function prepareCatalog( source: Extract, active: RawCatalog | null, routedModels: Awaited>, + multiAgentV2Enabled: boolean, + baseline: ReadonlyMap, + baselineCatalogModels: readonly Readonly>[], + degradedProviderNames: ReadonlySet, ): RawCatalog { const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog; const template = findNativeTemplate(catalog); @@ -173,39 +219,73 @@ function prepareCatalog( const exactComboSlugs = exactComboCatalogSlugs(config); const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); const enabledProviders = Object.entries(config.providers).filter(([, provider]) => provider.disabled !== true); - const includeNativeOpenAi = enabledProviders.length === 0 || enabledProviders.some(([name, provider]) => ( - name === "openai" && isCanonicalOpenAiForwardProvider(provider) - )); - const disabledNative = disabledNativeSlugs(config); - const nativeSlugs = includeNativeOpenAi - ? [...new Set((active?.models ?? catalog.models ?? []).flatMap(entry => ( - typeof entry.slug === "string" && !entry.slug.includes("/") && !disabledNative.has(entry.slug) - ? [entry.slug] : [] - )))] + const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); + const accountSelectors = shouldIncludeAccountBoundNativeOpenAi(config) + ? visibleCodexAccountSelectors(config) : []; - const entries = buildCatalogEntries( - template ? JSON.parse(JSON.stringify(template)) : null, - nativeSlugs, ordered, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs, + const catalogModels = active?.models ?? catalog.models ?? []; + const routedEntries = buildCatalogEntriesFromObservedState({ + template: template ? JSON.parse(JSON.stringify(template)) : null, + gptSlugs: [], + goModels: ordered, + featured, + wsEnabled: websocketsEnabled(config), + multiAgentMode, + exactComboSlugs, + accountSelectors, + multiAgentV2Enabled, + }); + const accountBoundEntries = accountSelectors.length === 0 + ? [] + : buildCatalogEntriesFromObservedState({ + template: template ? JSON.parse(JSON.stringify(template)) : null, + gptSlugs: NATIVE_OPENAI_MODELS, + goModels: [], + featured, + wsEnabled: websocketsEnabled(config), + multiAgentMode, + exactComboSlugs, + accountSelectors, + multiAgentV2Enabled, + }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined); + const gatheredProviderNames = new Set(enabledProviders.map(([name]) => name)); + const selectedModelsByProvider = new Map>( + enabledProviders.flatMap(([name, provider]) => ( + Array.isArray(provider.selectedModels) && provider.selectedModels.length > 0 + ? [[name, new Set(provider.selectedModels)] as const] + : [] + )), ); - if (entries.length === nativeSlugs.length) { - const configuredProviders = new Set(enabledProviders.map(([name]) => name)); - const preserved = (active?.models ?? []).filter(entry => { - if (typeof entry.slug !== "string" || !entry.slug.includes("/")) return false; - const provider = entry.slug.slice(0, entry.slug.indexOf("/")); - const description = typeof entry.description === "string" ? entry.description : ""; - return configuredProviders.has(provider) || !description.startsWith("Routed via opencodex → "); - }); - entries.push(...preserved); - } - if (!hasPhysicalComboProvider) { - const exact = exactComboSlugs; - catalog.models = entries.filter(entry => ( - typeof entry.slug !== "string" || !exact.has(entry.slug) - || (Array.isArray(entry.input_modalities) && entry.input_modalities.length > 0) - )); - } else { - catalog.models = entries; - } + const mergedModels = mergeCatalogEntriesFromObservedState({ + catalogModels, + baselineCatalogModels, + routedEntries, + baseline, + featured, + wsEnabled: websocketsEnabled(config), + template, + disabledModels: new Set(config.disabledModels ?? []), + selectedModelsByProvider, + gatheredProviderNames, + degradedProviderNames, + multiAgentMode, + multiAgentV2Enabled, + exactComboSlugs, + hasPhysicalComboProvider, + includeNativeOpenAi, + accountBoundEntries, + policy: { + ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + warningPolicy: "suppress", + }, + }); + clampCatalogModelsToObservedCodexSupport( + mergedModels, + source.runtimeSupport.kind === "available" + ? supportedCodexReasoningEffortsFromObservedCatalog(source.runtimeSupport.catalog) + : null, + ); + catalog.models = mergedModels; return catalog; } @@ -220,7 +300,7 @@ export async function gatherCodexCatalogCandidate( return { kind: "disposition", disposition: { status: "skipped", reason: "stale", retryable: true } }; } const paths = bindGatherPaths(session, snapshot); - const source = resolveCatalogSourceForGather(session); + const source = resolveCatalogSourceForGather(session, paths.catalogKind); if (source.kind === "catalog-unavailable") { return { kind: "disposition", disposition: { status: "skipped", reason: "catalog-unavailable", retryable: false } }; } @@ -234,10 +314,12 @@ export async function gatherCodexCatalogCandidate( readCatalogGatherSource(session, "native-catalog-selection"); } const authOutcomes: CatalogGatherProviderAuthOutcome[] = []; + const providerModelOutcomes: CatalogGatherProviderModelOutcome[] = []; const discoveryPolicies: CatalogProviderDiscoveryPolicySnapshot[] = []; providerGatherStarted = true; const routedModels = await gatherRoutedModelsForCatalogGather(snapshot.config, session, { providerAuthOutcomes: authOutcomes, + providerModelOutcomes, discoveryPolicySnapshots: discoveryPolicies, }); const processLocal = processEvidence(source); @@ -255,7 +337,30 @@ export async function gatherCodexCatalogCandidate( } const active = catalogFrom(activeBytes); - const preparedCatalog = prepareCatalog(snapshot.config, source, active, routedModels); + // Retained sync restores native priorities from the once-only pristine backup. Convergence + // must use the same admitted evidence instead of treating its already-featured active catalog + // as the baseline, or feature A -> feature B -> none would retain stale priorities. + const backupCanAcceptPristineCatalog = keyedBackupBytes === null + || (paths.legacyBackup !== undefined && legacyBackupBytes === null); + const baselineCatalog = catalogFrom(keyedBackupBytes) + ?? catalogFrom(legacyBackupBytes) + ?? (backupCanAcceptPristineCatalog + ? (active && !catalogHasRoutedEntries(active) + ? active + : !hasRoutedEntries(source.catalog) ? source.catalog : null) + : null); + const preparedCatalog = prepareCatalog( + snapshot.config, + source, + active, + routedModels, + paths.multiAgentV2Enabled, + nativePriorityBaseline(baselineCatalog), + baselineCatalog?.models ?? [], + new Set(providerModelOutcomes + .filter(outcome => outcome.state === "degraded") + .map(outcome => outcome.provider)), + ); const preparedCatalogBytes = catalogBytes(preparedCatalog); const preparedCacheBytes = `${JSON.stringify({ fetched_at: "2000-01-01T00:00:00Z", @@ -266,8 +371,17 @@ export async function gatherCodexCatalogCandidate( ? Buffer.from(activeBytes!).toString("utf8") : !hasRoutedEntries(source.catalog) ? `${JSON.stringify(source.catalog, null, 2)}\n` : null; const notices = new Set(); - if (source.source !== "bundled-catalog-template") notices.add("fallback"); - if (authOutcomes.some(outcome => outcome.state !== "available")) notices.add("provider-auth"); + const sourceIsAuthoritative = paths.catalogKind === "default" + ? source.source === "bundled-catalog-template" + : source.source === "active-catalog-merge"; + if (!sourceIsAuthoritative) notices.add("fallback"); + const authDegradedProviders = new Set(authOutcomes + .filter(outcome => outcome.state !== "available") + .map(outcome => outcome.provider)); + if (authDegradedProviders.size > 0) notices.add("provider-auth"); + if (providerModelOutcomes.some(outcome => ( + outcome.state === "degraded" && !authDegradedProviders.has(outcome.provider) + ))) notices.add("provider-network"); const candidate = {} as CodexCatalogCandidate; candidateStates.set(candidate, { consumed: false, diff --git a/src/codex/features.ts b/src/codex/features.ts index 489cb0e7d..ce7e77dbc 100644 --- a/src/codex/features.ts +++ b/src/codex/features.ts @@ -109,6 +109,11 @@ function tomlBoolInBody(body: string, key: string): boolean | null { */ export function isMultiAgentV2Enabled(configPath?: string): boolean { const content = readConfigText(configPath); + return multiAgentV2EnabledFromConfigText(content); +} + +/** Parse `multi_agent_v2` from caller-owned config.toml text without consulting disk. */ +export function multiAgentV2EnabledFromConfigText(content: string | null): boolean { if (content === null) return false; const table = tomlTableBody(content, "features.multi_agent_v2"); diff --git a/src/providers/slug-codec.ts b/src/providers/slug-codec.ts index c928c33d1..fb1a4c33c 100644 --- a/src/providers/slug-codec.ts +++ b/src/providers/slug-codec.ts @@ -56,12 +56,19 @@ export function slugEquals(stored: string, provider: string, id: string): boolea return stored === `${provider}/${id}` || stored === routedSlug(provider, id); } +/** Stable key for the exact equivalence relation used by catalog/config slug matching. */ +export function slugEquivalenceKey(slug: string): string { + const slash = slug.indexOf("/"); + return slash <= 0 + ? JSON.stringify(["exact", slug]) + : JSON.stringify([ + "routed", + slug.slice(0, slash), + encodeRoutedModelId(slug.slice(slash + 1)), + ]); +} + /** Equivalence between two routed slugs regardless of raw/encoded mix. */ export function slugsEquivalent(a: string, b: string): boolean { - if (a === b) return true; - const pa = a.indexOf("/"); - const pb = b.indexOf("/"); - if (pa <= 0 || pb <= 0) return false; - if (a.slice(0, pa) !== b.slice(0, pb)) return false; - return encodeRoutedModelId(a.slice(pa + 1)) === encodeRoutedModelId(b.slice(pb + 1)); + return a === b || slugEquivalenceKey(a) === slugEquivalenceKey(b); } diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index af8182a4d..122977fa8 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -25,7 +25,12 @@ On the default `opencodex-catalog.json` path, sync deliberately uses two catalog bundled catalog supplies a current native entry template, while the actual on-disk catalog supplies the rows being merged. 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. +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 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. Codex App model picker visibility comes from this shared catalog, not from patching the App. @@ -93,6 +98,8 @@ Pool mode routes across main plus added Codex credentials. Key rules: the Codex catalog clones each supported native row per selector and hides the bare picker rows; bare ids remain routable and stay in raw `/v1/models` unless explicitly disabled. Missing stored account targets are not advertised, and private account ids never become catalog labels. + `codexAccountPickerEnabled: false` hides generated rows without deleting exact routing bindings; + an omitted flag preserves the established behavior of a nonempty hand-written selector map. - **Rotation is sticky.** A conversation stays on its selected account while that account is usable; failure moves it, success does not (`src/codex/pool-rotation.ts`). - **The credential store is generation-guarded.** A refresh takes a lock and persists only if the diff --git a/tests/catalog-oauth-observation.test.ts b/tests/catalog-oauth-observation.test.ts index fa1fa144e..10838644f 100644 --- a/tests/catalog-oauth-observation.test.ts +++ b/tests/catalog-oauth-observation.test.ts @@ -19,6 +19,7 @@ import { import { gatherRoutedModelsForCatalogGather, type CatalogGatherProviderAuthOutcome, + type CatalogGatherProviderModelOutcome, } from "../src/codex/catalog/provider-fetch"; import { clearModelCache } from "../src/codex/model-cache"; import { getAuthRefreshIntentPath } from "../src/oauth/store"; @@ -93,15 +94,20 @@ function liveKimiProvider(onFetch: () => void): OcxProviderConfig { async function runCatalogGather( authStoreBuffer: Uint8Array | null, onFetch: () => void, -): Promise<{ rows: Awaited>; outcomes: CatalogGatherProviderAuthOutcome[] }> { +): Promise<{ + rows: Awaited>; + outcomes: CatalogGatherProviderAuthOutcome[]; + modelOutcomes: CatalogGatherProviderModelOutcome[]; +}> { const config: OcxConfig = { providers: { kimi: liveKimiProvider(onFetch) } }; const outcomes: CatalogGatherProviderAuthOutcome[] = []; + const modelOutcomes: CatalogGatherProviderModelOutcome[] = []; const rows = await gatherRoutedModelsForCatalogGather( config, { authStoreBuffer }, - { providerAuthOutcomes: outcomes }, + { providerAuthOutcomes: outcomes, providerModelOutcomes: modelOutcomes }, ); - return { rows, outcomes }; + return { rows, outcomes, modelOutcomes }; } beforeEach(() => { @@ -149,12 +155,16 @@ describe("catalog gather OAuth observation", () => { }; expect(observeActiveOAuthAccessToken("kimi", observedBuffer, now).kind).toBe("expired"); - const { rows, outcomes } = await runCatalogGather(observedBuffer, () => { outboundCalls += 1; }); + const { rows, outcomes, modelOutcomes } = await runCatalogGather( + observedBuffer, + () => { outboundCalls += 1; }, + ); expect(rows.map(row => row.id)).toEqual(["k3"]); expect(refreshCalls).toBe(0); expect(outboundCalls).toBe(0); expect(outcomes).toEqual([{ provider: "kimi", state: "expired" }]); + expect(modelOutcomes).toEqual([{ provider: "kimi", state: "degraded" }]); expectFileUnchanged(authPath, authBefore); expectFileUnchanged(intentPath, intentBefore); expect(readdirSync(opencodexHome).sort()).toEqual(listingBefore); @@ -176,10 +186,14 @@ describe("catalog gather OAuth observation", () => { }; expect(observeActiveOAuthAccessToken("kimi", observedBuffer).kind).toBe("malformed"); - const { rows, outcomes } = await runCatalogGather(observedBuffer, () => { outboundCalls += 1; }); + const { rows, outcomes, modelOutcomes } = await runCatalogGather( + observedBuffer, + () => { outboundCalls += 1; }, + ); expect(rows.map(row => row.id)).toEqual(["k3"]); expect(outcomes).toEqual([{ provider: "kimi", state: "malformed" }]); + expect(modelOutcomes).toEqual([{ provider: "kimi", state: "degraded" }]); expect(refreshCalls).toBe(0); expect(outboundCalls).toBe(0); expectFileUnchanged(authPath, before); @@ -203,10 +217,14 @@ describe("catalog gather OAuth observation", () => { }; expect(observeActiveOAuthAccessToken("kimi", observedBuffer, now).kind).toBe("available"); - const { rows, outcomes } = await runCatalogGather(observedBuffer, () => { outboundCalls += 1; }); + const { rows, outcomes, modelOutcomes } = await runCatalogGather( + observedBuffer, + () => { outboundCalls += 1; }, + ); expect(rows.map(row => row.id)).toEqual(["k3"]); expect(outcomes).toEqual([{ provider: "kimi", state: "available" }]); + expect(modelOutcomes).toEqual([{ provider: "kimi", state: "authoritative" }]); expect(refreshCalls).toBe(0); expect(outboundCalls).toBe(1); expectFileUnchanged(authPath, before); diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 7dfa2ace4..060254d0e 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -138,7 +138,7 @@ describe("Codex catalog sync hardening", () => { expect(slugs).not.toContain("codex-auto-review"); // legacy dropped }); - test("Gap A: an empty routed fetch preserves existing routed entries on disk", () => { + test("providers absent from config preserve foreign routed entries without an outage warning", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); writeFileSync(catalogPath, JSON.stringify({ @@ -149,21 +149,22 @@ describe("Codex catalog sync hardening", () => { ], }, null, 2) + "\n"); - // config has NO providers => gatherRoutedModels returns [] (transient empty fetch). + // No provider claims these foreign rows, so an empty gather preserves them without + // misreporting a provider outage. const r = runScript(codexHome, opencodexHome, ` const { syncCatalogModels } = require("./src/codex/catalog"); syncCatalogModels({ providers: {} }).then(res => console.log(JSON.stringify(res))); `); expect(r.status).toBe(0); - expect(r.stderr).toContain("routed model fetch returned empty; preserving 2 existing routed entries"); + expect(r.stderr).not.toContain("provider discovery degraded"); const slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); - expect(slugs).toContain("kiro/claude-opus-4.8"); // routed preserved despite empty fetch + expect(slugs).toContain("kiro/claude-opus-4.8"); expect(slugs).toContain("opencode-go/glm-5.2"); expect(slugs).toContain("gpt-5.5"); }); - test("account rows reconcile idempotently and independently from provider outages", () => { + test("account rows reconcile idempotently and independently from authoritative provider empties", () => { const catalogPath = join(codexHome, "catalog.json"); const firstCatalogPath = join(opencodexHome, "first-catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); @@ -232,7 +233,7 @@ describe("Codex catalog sync hardening", () => { await syncCatalogModels(config); `); expect(r.status).toBe(0); - expect(r.stderr).toContain("routed model fetch returned empty; preserving 2 existing routed entries"); + expect(r.stderr).not.toContain("provider discovery degraded"); expect(r.stderr).not.toContain("account selector collision"); const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ @@ -470,7 +471,7 @@ describe("Codex catalog sync hardening", () => { syncCatalogModels({ providers: {} }).then(res => console.log(JSON.stringify(res))); `, { CODEX_CLI_PATH: codexCliPath }); expect(r.status).toBe(0); - expect(r.stderr).toContain("routed model fetch returned empty; preserving 2 existing routed entries"); + expect(r.stderr).not.toContain("provider discovery degraded"); const slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); expect(slugs).toContain("gpt-5.5"); @@ -479,7 +480,7 @@ describe("Codex catalog sync hardening", () => { expect(slugs).toContain("opencode-go/glm-5.2"); }); - test("empty routed refresh drops compatibility-excluded rows while preserving other routed entries", () => { + test("provider absence drops compatibility-excluded rows while preserving foreign routed entries", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); writeFileSync(catalogPath, JSON.stringify({ @@ -496,7 +497,7 @@ describe("Codex catalog sync hardening", () => { syncCatalogModels({ providers: {} }).then(res => console.log(JSON.stringify(res))); `); expect(r.status).toBe(0); - expect(r.stderr).toContain("routed model fetch returned empty; preserving 2 existing routed entries"); + expect(r.stderr).not.toContain("provider discovery degraded"); const slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); expect(slugs).toContain("kiro/claude-opus-4.8"); @@ -592,7 +593,7 @@ describe("Codex catalog sync hardening", () => { expect(slugs).toContain("openai/fresh-model"); }); - test("empty-gather transient protection still drops deleted-provider ghost rows", () => { + test("authoritative empty providers drop their own rows and deleted-provider ghosts", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); writeFileSync(catalogPath, JSON.stringify({ @@ -604,10 +605,8 @@ describe("Codex catalog sync hardening", () => { ], }, null, 2) + "\n"); - // A configured provider that gathers zero rows: sync takes the - // preserve-existing branch. The deleted provider's authored row must - // still go; the configured provider's authored row and the foreign row - // stay (transient protection). + // Static discovery is authoritative even when its configured allowlist is empty. Both the + // configured provider's stale row and the deleted provider's ghost must go; foreign rows stay. const r = runScript(codexHome, opencodexHome, ` const { syncCatalogModels } = require("./src/codex/catalog"); syncCatalogModels({ @@ -625,7 +624,48 @@ describe("Codex catalog sync hardening", () => { const slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); expect(slugs).not.toContain("future-grok/old-model"); - expect(slugs).toContain("openai/keep-model"); + expect(slugs).not.toContain("openai/keep-model"); + expect(slugs).toContain("cursor/composer-2.5"); + }); + + test("a degraded provider preserves only its own prior rows", () => { + const catalogPath = join(codexHome, "catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(catalogPath, JSON.stringify({ + models: [ + nativeEntry("gpt-5.5", 0), + ocxAuthoredEntry("offline/keep-model", 5), + ocxAuthoredEntry("offline/disabled-model", 6), + ocxAuthoredEntry("removed/ghost", 7), + routedEntry("cursor/composer-2.5", 8), + ], + }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + globalThis.fetch = async () => new Response("{}", { status: 503 }); + const { syncCatalogModels } = require("./src/codex/catalog"); + syncCatalogModels({ + disabledModels: ["offline/disabled-model"], + providers: { + offline: { + adapter: "openai-chat", + authMode: "key", + apiKey: "fixture-key", + baseUrl: "https://api.example.test/v1", + allowPrivateNetwork: true, + models: ["fallback-model"] + } + } + }).then(res => console.log(JSON.stringify(res))); + `); + expect(r.status).toBe(0); + expect(r.stderr).toContain("provider discovery degraded; preserving 1 existing routed entry"); + + const slugs = (JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ slug: string }>).map(m => m.slug); + expect(slugs).toContain("offline/keep-model"); + expect(slugs).toContain("offline/fallback-model"); + expect(slugs).not.toContain("offline/disabled-model"); + expect(slugs).not.toContain("removed/ghost"); expect(slugs).toContain("cursor/composer-2.5"); }); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 1f6896f8a..a78a64a84 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, shouldExposeRoutedModel } from "../src/codex/catalog"; +import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, CODEX_ACCOUNT_BOUND_CATALOG_KIND, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, shouldExposeRoutedModel } from "../src/codex/catalog"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND } from "../src/codex/catalog/parsing"; import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; import { CURSOR_STATIC_MODELS, @@ -26,6 +27,15 @@ import type { NormalizedComboConfig } from "../src/combos/types"; import { enrichProviderFromRegistry } from "../src/providers/derive"; import { handleManagementAPI } from "../src/server/management-api"; import { OAUTH_PROVIDERS } from "../src/oauth"; +import { + clampCatalogModelsToObservedCodexSupport, + supportedCodexReasoningEffortsFromObservedCatalog, +} from "../src/codex/catalog/effort"; +import { + CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + mergeCatalogEntriesFromObservedState, + type ObservedCatalogMergeInput, +} from "../src/codex/catalog/sync"; const originalFetch = globalThis.fetch; @@ -463,16 +473,358 @@ describe("combo catalog capability intersection", () => { expect(merged.some(entry => entry.slug === "combo/hidden")).toBe(false); }); + test("an inadmissible combo alias cannot suppress an existing catalog row", () => { + for (const [slug, owner] of [ + ["user-native", "user"], + ["vendor/model", "user"], + ["unowned/model", undefined], + ] as const) { + const existing = { + ...nativeTemplate(), + slug, + display_name: "User catalog row", + ...(owner === undefined ? {} : { owned_by: owner }), + }; + const malformedCombo = { + ...nativeTemplate(), + slug, + display_name: slug, + owned_by: "combo", + input_modalities: [], + }; + const merged = mergeCatalogEntriesForSync( + [existing], [malformedCombo], new Map(), [], false, new Set(), nativeTemplate(), new Set(), + new Set(["combo"]), "default", new Set([slug]), false, + ); + + const surviving = merged.filter(entry => entry.slug === slug); + expect(surviving).toEqual([expect.objectContaining({ display_name: "User catalog row" })]); + if (slug.includes("/")) expect(surviving[0]?.input_modalities).toEqual(["text"]); + } + }); + + test("a provider row sharing an omitted combo alias keeps ordinary routed normalization", () => { + const entries = buildCatalogEntries( + nativeTemplate(), + [], + [{ provider: "vendor", id: "model" }], + undefined, + false, + "default", + new Set(["vendor/model"]), + ); + + expect(entries.find(entry => entry.slug === "vendor/model")).toMatchObject({ + input_modalities: ["text"], + supports_parallel_tool_calls: false, + }); + }); + + test("a removed bare combo alias restores the pristine row it shadowed", () => { + const slug = "user-native"; + const pristine = { + ...nativeTemplate(), + slug, + display_name: "User catalog row", + owned_by: "user", + user_marker: "pristine", + }; + const combo = { + ...nativeTemplate(), + slug, + display_name: slug, + owned_by: "combo", + input_modalities: ["text"], + }; + const shadowed = mergeObservedForTest({ + catalogModels: [pristine], + baselineCatalogModels: [pristine], + routedEntries: [combo], + gatheredProviderNames: new Set(["combo"]), + exactComboSlugs: new Set([slug]), + }); + expect(shadowed.filter(entry => entry.slug === slug)).toEqual([ + expect.objectContaining({ owned_by: "combo" }), + ]); + + const restored = mergeObservedForTest({ + catalogModels: shadowed, + baselineCatalogModels: [pristine], + routedEntries: [], + }); + expect(restored.filter(entry => entry.slug === slug)).toEqual([ + expect.objectContaining({ + display_name: "User catalog row", + owned_by: "user", + user_marker: "pristine", + }), + ]); + }); + + test("a removed slashed combo alias restores its pristine foreign row", () => { + const slug = "vendor/model"; + const pristine = { + ...nativeTemplate(), + slug, + display_name: "Foreign catalog row", + foreign_marker: "pristine", + }; + const combo = { + ...nativeTemplate(), + slug, + owned_by: "combo", + input_modalities: ["text"], + }; + const shadowed = mergeObservedForTest({ + catalogModels: [pristine], + baselineCatalogModels: [pristine], + routedEntries: [combo], + exactComboSlugs: new Set([slug]), + }); + expect(shadowed.filter(entry => entry.slug === slug)).toEqual([ + expect.objectContaining({ owned_by: "combo" }), + ]); + + const restored = mergeObservedForTest({ + catalogModels: shadowed, + baselineCatalogModels: [pristine], + routedEntries: [], + }); + expect(restored.filter(entry => entry.slug === slug)).toEqual([ + expect.objectContaining({ + display_name: "Foreign catalog row", + foreign_marker: "pristine", + }), + ]); + }); + + test("a combo cannot shadow an irreplaceable catalog row without a pristine restore point", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + for (const [slug, owner, gatheredProviders, degradedProviders] of [ + ["user-native-no-backup", "user", new Set(["target"]), new Set()], + ["vendor/model-no-backup", undefined, new Set(["target"]), new Set()], + ["offline/model-no-backup", undefined, new Set(["offline"]), new Set(["offline"])], + ] as const) { + const existing = { + ...nativeTemplate(), + slug, + display_name: "Irreplaceable user catalog row", + ...(owner === undefined ? {} : { owned_by: owner }), + }; + const combo = { + ...nativeTemplate(), + slug, + display_name: slug, + owned_by: "combo", + input_modalities: ["text"], + }; + const merged = mergeObservedForTest({ + catalogModels: [existing], + routedEntries: [combo], + gatheredProviderNames: gatheredProviders, + degradedProviderNames: degradedProviders, + exactComboSlugs: new Set([slug]), + }); + + expect(merged.filter(entry => entry.slug === slug)).toEqual([ + expect.objectContaining({ display_name: "Irreplaceable user catalog row" }), + ]); + } + expect(warn.mock.calls.filter(call => String(call[0]).includes("no pristine backup"))) + .toHaveLength(3); + } finally { + warn.mockRestore(); + } + }); + + test("the legacy merge wrapper does not infer provider authority from a slashed combo alias", () => { + const slug = "vendor/model-without-backup"; + const existing = { + ...nativeTemplate(), + slug, + display_name: "Foreign catalog row", + user_marker: "keep", + }; + const combo = { + ...nativeTemplate(), + slug, + display_name: slug, + owned_by: "combo", + input_modalities: ["text"], + }; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const shadowAttempt = mergeCatalogEntriesForSync( + [existing], [combo], new Map(), [], false, new Set(), nativeTemplate(), new Set(), + undefined, "default", new Set([slug]), false, + ); + expect(shadowAttempt.filter(entry => entry.slug === slug)).toEqual([ + expect.objectContaining({ + display_name: "Foreign catalog row", + user_marker: "keep", + }), + ]); + expect(shadowAttempt.find(entry => entry.slug === slug)?.owned_by).not.toBe("combo"); + + const afterRemoval = mergeCatalogEntriesForSync( + shadowAttempt, [], new Map(), [], false, + ); + expect(afterRemoval.filter(entry => entry.slug === slug)).toEqual([ + expect.objectContaining({ + display_name: "Foreign catalog row", + user_marker: "keep", + }), + ]); + } finally { + warn.mockRestore(); + } + }); + + test("an authoritative configured namespace remains replaceable by a combo alias", () => { + const slug = "vendor/authoritative-model"; + const existing = { + ...nativeTemplate(), + slug, + display_name: "Old foreign-shaped row", + }; + const combo = { + ...nativeTemplate(), + slug, + owned_by: "combo", + input_modalities: ["text"], + }; + const merged = mergeObservedForTest({ + catalogModels: [existing], + routedEntries: [combo], + gatheredProviderNames: new Set(["vendor"]), + exactComboSlugs: new Set([slug]), + }); + + expect(merged.filter(entry => entry.slug === slug)).toEqual([ + expect.objectContaining({ owned_by: "combo" }), + ]); + }); + + test("generated provider and custom rows do not masquerade as irreplaceable combo shadows", () => { + const slug = "managed/model"; + const combo = { + ...nativeTemplate(), + slug, + owned_by: "combo", + input_modalities: ["text"], + }; + for (const generated of [ + { + ...nativeTemplate(), + slug, + description: "Routed via opencodex → managed/model (managed).", + }, + { + ...nativeTemplate(), + slug, + opencodex_catalog_kind: CODEX_CUSTOM_MODEL_CATALOG_KIND, + }, + ]) { + const merged = mergeObservedForTest({ + catalogModels: [generated], + routedEntries: [combo], + gatheredProviderNames: new Set(["managed"]), + exactComboSlugs: new Set([slug]), + }); + + expect(merged.filter(entry => entry.slug === slug)).toEqual([ + expect.objectContaining({ owned_by: "combo" }), + ]); + } + }); + + test("a fresh account row wins without a false unrestorable-shadow warning", () => { + const slug = "team/gpt-5.5"; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const account = { + ...nativeTemplate(), + slug, + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + }; + const combo = { + ...nativeTemplate(), + slug, + owned_by: "combo", + input_modalities: ["text"], + }; + const merged = mergeObservedForTest({ + catalogModels: [{ ...nativeTemplate(), slug, display_name: "Old foreign row" }], + routedEntries: [combo], + exactComboSlugs: new Set([slug]), + accountBoundEntries: [account], + }); + + expect(merged.filter(entry => entry.slug === slug)).toEqual([ + expect.objectContaining({ opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND }), + ]); + expect(warn.mock.calls.some(call => String(call[0]).includes("no pristine backup"))).toBe(false); + } finally { + warn.mockRestore(); + } + }); + + test("unrestorable-shadow warnings dedupe equivalent slugs, reset, and stay caller-owned", () => { + resetCatalogRuntimeStateForTests(); + const rawSlug = "foreign/org/model"; + const encodedSlug = "foreign/org-model"; + const existing = { ...nativeTemplate(), slug: rawSlug, display_name: "Foreign row" }; + const combo = { + ...nativeTemplate(), + slug: encodedSlug, + owned_by: "combo", + input_modalities: ["text"], + }; + const input = { + catalogModels: [existing], + routedEntries: [combo], + exactComboSlugs: new Set([encodedSlug]), + }; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + mergeObservedForTest({ + ...input, + policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "suppress" }, + }); + expect(warn).not.toHaveBeenCalled(); + + mergeObservedForTest(input); + mergeObservedForTest({ + ...input, + catalogModels: [{ ...existing, slug: encodedSlug }], + }); + expect(warn.mock.calls.filter(call => String(call[0]).includes("no pristine backup"))) + .toHaveLength(1); + + resetCatalogRuntimeStateForTests(); + mergeObservedForTest(input); + expect(warn.mock.calls.filter(call => String(call[0]).includes("no pristine backup"))) + .toHaveLength(2); + } finally { + warn.mockRestore(); + } + }); + test("uses config identity for physical preservation and stale virtual cleanup", () => { const physical = { slug: "combo/model", supported_reasoning_levels: [{ effort: "low" }], input_modalities: ["text"], }; - const preserved = mergeCatalogEntriesForSync( - [physical], [], new Map(), [], false, new Set(), null, new Set(), new Set(["combo"]), - "default", new Set(), true, - ).find(entry => entry.slug === "combo/model"); + const preserved = mergeObservedForTest({ + catalogModels: [physical], + routedEntries: [], + template: null, + gatheredProviderNames: new Set(["combo"]), + degradedProviderNames: new Set(["combo"]), + hasPhysicalComboProvider: true, + }).find(entry => entry.slug === "combo/model"); expect(preserved).toBeDefined(); expect((preserved?.supported_reasoning_levels as Array<{ effort: string }>).map(level => level.effort)) .toEqual(["low", "max"]); @@ -851,16 +1203,34 @@ describe("configured CatalogModel displayName -> catalog display_name", () => { // rides only on the custom row. const custom = models.find(m => m.provider === "custom-provider" && m.id === "renamed-model"); expect(custom?.displayName).toBe("Renamed Model"); + expect(custom?.catalogKind).toBe(CODEX_CUSTOM_MODEL_CATALOG_KIND); const entries = buildCatalogEntries(nativeTemplate(), [], models); const row = entries.find(e => e.slug === "custom-provider/renamed-model"); expect(row?.display_name).toBe("Renamed Model"); expect(row?.slug).toBe("custom-provider/renamed-model"); + expect(row?.opencodex_catalog_kind).toBe(CODEX_CUSTOM_MODEL_CATALOG_KIND); } finally { globalThis.fetch = originalFetch; clearModelCache("custom-provider"); } }); + + test("degraded provider preservation cannot resurrect a deleted custom model", () => { + const staleCustom = buildCatalogEntries(nativeTemplate(), [], [{ + provider: "custom-provider", + id: "removed-model", + catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, + }]); + const merged = mergeObservedForTest({ + catalogModels: staleCustom, + routedEntries: [], + gatheredProviderNames: new Set(["custom-provider"]), + degradedProviderNames: new Set(["custom-provider"]), + }); + + expect(merged.some(entry => entry.slug === "custom-provider/removed-model")).toBe(false); + }); }); test("a custom row inherits provider reasoning metadata from the provider-derived row it replaces (#962)", async () => { @@ -964,6 +1334,34 @@ function nativeTemplate(): Record { }; } +function mergeObservedForTest( + input: Pick + & Partial, +): Record[] { + return mergeCatalogEntriesFromObservedState({ + baselineCatalogModels: [], + baseline: new Map(), + featured: [], + wsEnabled: false, + template: nativeTemplate(), + disabledModels: new Set(), + selectedModelsByProvider: new Map(), + gatheredProviderNames: new Set(), + degradedProviderNames: new Set(), + multiAgentMode: "default", + multiAgentV2Enabled: false, + exactComboSlugs: new Set(), + hasPhysicalComboProvider: false, + includeNativeOpenAi: true, + accountBoundEntries: [], + policy: { + ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, + warningPolicy: "emit", + }, + ...input, + }); +} + describe("Codex catalog routed normalization", () => { test("canonical OpenAI forward mode stays native-only with no routed duplicate", async () => { globalThis.fetch = (() => { throw new Error("forward providers must not fetch /models"); }) as typeof fetch; @@ -1213,6 +1611,54 @@ describe("Codex catalog routed normalization", () => { expect(sol?.priority).toBe(1); }); + test("fallback-quality upgrades restore snapshot metadata without losing pristine priority", () => { + const synthesizedSol = { + ...nativeTemplate(), + slug: "gpt-5.6-sol", + display_name: "gpt-5.6-sol", + priority: 2, + stale_marker: true, + }; + + const merged = mergeCatalogEntriesForSync( + [synthesizedSol], [], new Map([["gpt-5.6-sol", 41]]), [], false, + ); + const sol = merged.find(entry => entry.slug === "gpt-5.6-sol"); + + expect(sol?.display_name).toBe("GPT-5.6-Sol"); + expect(sol?.description).toBe("Latest frontier agentic coding model."); + expect(sol?.priority).toBe(41); + expect(sol).not.toHaveProperty("stale_marker"); + }); + + test("backfilled natives and account clones use the pristine native baseline", () => { + const accountRows = buildCatalogEntries( + nativeTemplate(), + NATIVE_OPENAI_MODELS, + [], + undefined, + false, + "default", + new Set(), + ["team"], + ).filter(entry => entry.opencodex_catalog_kind === CODEX_ACCOUNT_BOUND_CATALOG_KIND); + + for (const priority of [41, 9]) { + const merged = mergeCatalogEntriesForSync( + [], [], new Map([["gpt-5.6-sol", priority]]), [], false, new Set(), nativeTemplate(), + new Set(), new Set(), "default", new Set(), false, true, accountRows, + ); + const bare = merged.find(entry => entry.slug === "gpt-5.6-sol"); + const account = merged.find(entry => entry.slug === "team/gpt-5.6-sol"); + + expect(bare?.priority).toBe(priority); + expect(account?.base_instructions).toBe(bare?.base_instructions); + expect(account?.description).toBe(bare?.description); + expect(account?.comp_hash).toBe(bare?.comp_hash); + expect(account?.opencodex_catalog_kind).toBe(CODEX_ACCOUNT_BOUND_CATALOG_KIND); + } + }); + test("routed entries drop stale native max context with the template window (#992)", () => { const template = { ...nativeTemplate(), @@ -1252,7 +1698,11 @@ describe("Codex catalog routed normalization", () => { }); test("catalog sync keeps native OpenAI rows when adopted providers expose matching ids", () => { - const native = nativeTemplate(); + const native = { + ...nativeTemplate(), + base_instructions: "installed native instructions", + genuine_marker: "installed-native", + }; const nativeMini = { ...nativeTemplate(), slug: "gpt-5.4-mini", @@ -1273,6 +1723,7 @@ describe("Codex catalog routed normalization", () => { ]), [], false, + new Set(["gpt-5.5", "gpt-5.4-mini"]), ); const slugs = merged.map(entry => entry.slug); @@ -1282,6 +1733,10 @@ describe("Codex catalog routed normalization", () => { expect(slugs).toContain("cursor/gpt-5.4-mini"); expect(slugs).not.toContain("cursor/old"); expect(merged.find(entry => entry.slug === "gpt-5.5")?.priority).toBe(9); + expect(merged.find(entry => entry.slug === "gpt-5.5")?.base_instructions) + .toBe("installed native instructions"); + expect(merged.find(entry => entry.slug === "gpt-5.5")?.genuine_marker) + .toBe("installed-native"); expect(merged.find(entry => entry.slug === "gpt-5.4-mini")?.priority).toBe(10); }); @@ -1888,23 +2343,35 @@ describe("Codex catalog routed normalization", () => { test("HTTP non-OK discovery returns configured models with status diagnostics", async () => { const provider = "discovery-http-401"; const warning = spyOn(console, "warn").mockImplementation(() => {}); - globalThis.fetch = (async () => new Response(null, { status: 401 })) as typeof fetch; + let fetchCalls = 0; + globalThis.fetch = (async () => { + fetchCalls += 1; + return new Response(null, { status: 401 }); + }) as typeof fetch; + const liveOutcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + const cooldownOutcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + const config = { + providers: { + [provider]: { + adapter: "openai-chat" as const, + baseUrl: "https://93.184.216.34/v1", + apiKey: "sk-test", + models: ["static-fallback"], + }, + }, + }; try { - const models = await gatherRoutedModels({ - providers: { - [provider]: { - adapter: "openai-chat", - baseUrl: "https://93.184.216.34/v1", - apiKey: "sk-test", - models: ["static-fallback"], - }, - }, - }); + const models = await gatherRoutedModels(config, { providerModelOutcomes: liveOutcomes }); + const cooled = await gatherRoutedModels(config, { providerModelOutcomes: cooldownOutcomes }); expect(models.map(model => `${model.provider}/${model.id}`)).toEqual([ `${provider}/static-fallback`, ]); + expect(cooled).toEqual(models); + expect(fetchCalls).toBe(1); + expect(liveOutcomes).toEqual([{ provider, state: "degraded" }]); + expect(cooldownOutcomes).toEqual([{ provider, state: "degraded" }]); expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "failed", reason: "http", @@ -2098,14 +2565,18 @@ describe("Codex catalog routed normalization", () => { }, }, }; + const liveOutcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + const cacheOutcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; try { - const first = await gatherRoutedModels(config); - const second = await gatherRoutedModels(config); + const first = await gatherRoutedModels(config, { providerModelOutcomes: liveOutcomes }); + const second = await gatherRoutedModels(config, { providerModelOutcomes: cacheOutcomes }); expect(first).toEqual([]); expect(second).toEqual([]); expect(fetchCalls).toBe(1); + expect(liveOutcomes).toEqual([{ provider, state: "authoritative" }]); + expect(cacheOutcomes).toEqual([{ provider, state: "authoritative" }]); expect(getStaleCached(provider)).toEqual([]); expect(getProviderDiscoveryStatus(provider)).toEqual({ status: "ok" }); const warningText = warning.mock.calls.flat().join(" "); @@ -2266,6 +2737,7 @@ describe("Codex catalog routed normalization", () => { }); test("liveModels false with no models exposes no augmented provider rows", async () => { + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; const models = await gatherRoutedModels({ providers: { "opencode-go": { @@ -2275,9 +2747,10 @@ describe("Codex catalog routed normalization", () => { liveModels: false, }, }, - }); + }, { providerModelOutcomes: outcomes }); expect(models).toEqual([]); + expect(outcomes).toEqual([{ provider: "opencode-go", state: "authoritative" }]); }); test("anthropic sonnet 4.6 keeps the upstream 1M context window", () => { @@ -2741,6 +3214,40 @@ describe("OpenAI API trusted catalog augmentation", () => { } }); + test("trusted OpenAI API reconstruction is authoritative after discovery failure", async () => { + const warning = spyOn(console, "warn").mockImplementation(() => {}); + globalThis.fetch = async () => new Response("{}", { status: 503 }); + const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + try { + const rows = await gatherRoutedModels( + openAiApiCatalogConfig({ liveModels: true, allowPrivateNetwork: true }), + { providerModelOutcomes: outcomes }, + ); + expect(rows.filter(row => row.provider === "openai-apikey").map(row => row.id)) + .toEqual([...exactIds].sort()); + expect(outcomes).toEqual([{ provider: "openai-apikey", state: "authoritative" }]); + + const routed = buildCatalogEntries(nativeTemplate(), [], rows); + const ghost = { + ...nativeTemplate(), + slug: "openai-apikey/removed-ghost", + description: "Routed via opencodex → openai-apikey (openai-apikey).", + }; + const degraded = new Set(outcomes + .filter(outcome => outcome.state === "degraded") + .map(outcome => outcome.provider)); + const merged = mergeObservedForTest({ + catalogModels: [nativeTemplate(), ghost], + routedEntries: routed, + gatheredProviderNames: new Set(["openai-apikey"]), + degradedProviderNames: degraded, + }); + expect(merged.some(entry => entry.slug === "openai-apikey/removed-ghost")).toBe(false); + } finally { + warning.mockRestore(); + } + }); + test("actual gathering exposes no API rows when the API tier is absent or disabled", async () => { globalThis.fetch = async input => { throw new Error(`unexpected fetch: ${String(input)}`); }; const absent: OcxConfig = { @@ -2906,6 +3413,32 @@ describe("Codex reasoning-effort capability clamp", () => { }; } + test("the observed-state clamp is pure with respect to frozen runtime evidence", () => { + const observed = Object.freeze({ + models: Object.freeze([Object.freeze({ + slug: "gpt-5.5", + supported_reasoning_levels: Object.freeze( + ["low", "medium", "high", "xhigh"].map(effort => Object.freeze({ effort })), + ), + })]), + }); + const before = JSON.stringify(observed); + const models = [routedEntry()]; + models[0]!.default_reasoning_level = "ultra"; + + const supported = supportedCodexReasoningEffortsFromObservedCatalog(observed); + const clamp = clampCatalogModelsToObservedCodexSupport(models, supported); + + expect(clamp).toEqual({ + removedEfforts: ["max", "ultra"], + affectedModels: ["openrouter/example"], + }); + expect(models[0]!.supported_reasoning_levels.map(level => level.effort)) + .toEqual(["low", "medium", "high", "xhigh"]); + expect(models[0]!.default_reasoning_level).toBe("xhigh"); + expect(JSON.stringify(observed)).toBe(before); + }); + test("strips max and ultra when the installed Codex ladder stops at xhigh", () => { const models = [routedEntry()]; diff --git a/tests/codex-convergence-account-selectors.test.ts b/tests/codex-convergence-account-selectors.test.ts new file mode 100644 index 000000000..17c87e0e1 --- /dev/null +++ b/tests/codex-convergence-account-selectors.test.ts @@ -0,0 +1,827 @@ +import { afterEach, beforeEach, expect, spyOn, test } from "bun:test"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { captureCatalogAdmissionSnapshot } from "../src/codex/catalog-admission"; +import { + CODEX_ACCOUNT_BOUND_CATALOG_KIND, + loadBundledCodexCatalog, + NATIVE_OPENAI_MODELS, + resetCatalogRuntimeStateForTests, + syncCatalogModels, +} from "../src/codex/catalog"; +import { + commitCodexCatalogCandidate, + convergeCodexCatalog, + gatherCodexCatalogCandidate, +} from "../src/codex/convergence"; +import { + catalogBackupPathFor, + type RawCatalog, + type RawEntry, +} from "../src/codex/catalog/parsing"; +import { + resolveCodexCatalogSerializationDatabasePath, + resolveEffectiveUserIdentity, +} from "../src/codex/user-identity"; +import { saveConfig } from "../src/config"; +import { CODEX_FORWARD_BASE_URL } from "../src/providers/openai-tiers"; +import type { OcxConfig } from "../src/types"; +import { setBundledCatalogCacheForTests } from "../src/codex/catalog/bundled"; +import { + resetCodexRuntimeResolveCacheForTests, + setCodexRuntimeResolveCacheForTests, +} from "../src/codex/runtime"; +import { markModelsFetchFailure } from "../src/codex/model-cache"; + +let root = ""; +let codexHome = ""; +let opencodexHome = ""; +let catalogPath = ""; +let previousCodexHome: string | undefined; +let previousOpencodexHome: string | undefined; +let previousCodexCliPath: string | undefined; + +function nativeEntry(visibility = "list"): RawEntry { + return { + slug: "gpt-5.6-sol", + display_name: "GPT-5.6-Sol", + description: "Native", + priority: 1, + visibility, + base_instructions: "You are Codex.", + supported_reasoning_levels: [{ effort: "medium", description: "Medium" }], + }; +} + +function accountEntry(selector: string): RawEntry { + return { + ...nativeEntry(), + slug: `${selector}/gpt-5.6-sol`, + display_name: `${selector} / 5.6 Sol`, + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + }; +} + +function unsupportedNativeEntry(): RawEntry { + return { + ...nativeEntry(), + slug: "gpt-legacy-unsupported", + display_name: "GPT Legacy Unsupported", + }; +} + +function foreignEntry(): RawEntry { + return { + slug: "external/vendor-model", + display_name: "External model", + description: "Managed by another catalog tool.", + priority: 50, + visibility: "list", + base_instructions: "You are an external model.", + }; +} + +function foreignBareNativeEntry(): RawEntry { + return { + ...nativeEntry(), + slug: "user-native", + display_name: "User native", + description: "Managed by another catalog tool.", + }; +} + +function generatedRoutedEntry(slug: string, marker?: string): RawEntry { + const provider = slug.slice(0, slug.indexOf("/")); + return { + ...nativeEntry(), + slug, + display_name: slug, + description: `Routed via opencodex → ${provider} (${provider}).`, + owned_by: provider, + ...(marker ? { test_marker: marker } : {}), + }; +} + +function nativeMetadataEntry( + slug: "gpt-5.5" | "gpt-5.4", + baseInstructions: string, + priority: number, +): RawEntry { + return { + slug, + display_name: slug === "gpt-5.5" ? "GPT-5.5 Live" : "GPT-5.4 Live", + description: `${slug} installed metadata`, + priority, + visibility: "list", + base_instructions: baseInstructions, + supported_reasoning_levels: [{ effort: "medium", description: `${slug} medium` }], + }; +} + +function config(pickerEnabled: boolean, disabledModels: string[] = []): OcxConfig { + return { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: CODEX_FORWARD_BASE_URL, + authMode: "forward", + }, + }, + defaultProvider: "openai", + codexAccounts: [{ + id: "side-account-id", + email: "side@example.test", + alias: "Private Side Account", + isMain: false, + }], + codexAccountNamespaces: { + desktop: "@main", + team: "side-account-id", + }, + codexAccountPickerEnabled: pickerEnabled, + disabledModels, + }; +} + +function writeCatalog(models: RawEntry[]): void { + writeFileSync(catalogPath, `${JSON.stringify({ models }, null, 2)}\n`); +} + +function seedObservedRuntimeSupport(efforts = ["low", "medium", "high", "xhigh"]): void { + rmSync(join(opencodexHome, "codex-runtime.json"), { force: true }); + const runtime = { + command: "/tmp/codex", + version: "0.145.0", + source: "fallback" as const, + }; + setCodexRuntimeResolveCacheForTests( + { runtime, failures: [] }, + { discoverAlternatives: false }, + ); + setBundledCatalogCacheForTests(runtime, { + models: [{ + ...nativeMetadataEntry("gpt-5.5", "Bundled runtime instructions.", 9), + supported_reasoning_levels: efforts.map(effort => ({ effort, description: effort })), + default_reasoning_level: "medium", + }], + }); +} + +function createCodexRuntimeFixture(efforts = ["low", "medium", "high", "xhigh"]): string { + const scriptPath = join(root, "codex-runtime-fixture.js"); + const bundled = JSON.stringify({ + models: [{ + ...nativeMetadataEntry("gpt-5.5", "Fixture runtime instructions.", 9), + supported_reasoning_levels: efforts.map(effort => ({ effort, description: effort })), + default_reasoning_level: "medium", + }], + }); + writeFileSync(scriptPath, [ + 'if (process.argv.includes("--version")) {', + ' console.log("codex-cli 0.145.0");', + '} else {', + ` process.stdout.write(${JSON.stringify(bundled)});`, + '}', + ].join("\n")); + + if (process.platform === "win32") { + const commandPath = join(root, "codex-runtime-fixture.cmd"); + writeFileSync(commandPath, `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`); + return commandPath; + } + + const commandPath = join(root, "codex-runtime-fixture"); + writeFileSync(commandPath, `#!/bin/sh\nexec "${process.execPath}" "${scriptPath}" "$@"\n`); + chmodSync(commandPath, 0o755); + return commandPath; +} + +function primeCodexRuntimeFixture(): void { + process.env.CODEX_CLI_PATH = createCodexRuntimeFixture(); + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); + expect(loadBundledCodexCatalog()?.models?.[0]?.slug).toBe("gpt-5.5"); +} + +async function convergeCatalog(nextConfig: OcxConfig): Promise { + saveConfig(nextConfig); + const gathered = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(nextConfig)); + expect(gathered.kind).toBe("candidate"); + if (gathered.kind !== "candidate") throw new Error("expected a catalog candidate"); + expect((await commitCodexCatalogCandidate(gathered.candidate, 1_000)).kind).toBe("committed"); + return JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog; +} + +async function convergeCatalogDisposition(nextConfig: OcxConfig) { + saveConfig(nextConfig); + return (await convergeCodexCatalog( + captureCatalogAdmissionSnapshot(nextConfig), + { + action: "converge", + scope: "catalog", + reason: "management-mutation", + mode: "explicit", + deadlineMs: 1_000, + }, + )).catalogRefresh; +} + +beforeEach(() => { + previousCodexHome = process.env.CODEX_HOME; + previousOpencodexHome = process.env.OPENCODEX_HOME; + previousCodexCliPath = process.env.CODEX_CLI_PATH; + root = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-convergence-accounts-"))); + codexHome = join(root, "codex"); + opencodexHome = join(root, "opencodex"); + catalogPath = join(codexHome, "opencodex-catalog.json"); + mkdirSync(codexHome); + mkdirSync(opencodexHome); + process.env.CODEX_HOME = codexHome; + process.env.OPENCODEX_HOME = opencodexHome; + resetCatalogRuntimeStateForTests(); + resetCodexRuntimeResolveCacheForTests(); +}); + +afterEach(() => { + const identity = resolveEffectiveUserIdentity(); + const serializationDb = resolveCodexCatalogSerializationDatabasePath(identity, codexHome); + for (const suffix of ["", "-journal", "-wal", "-shm"]) { + rmSync(`${serializationDb}${suffix}`, { force: true }); + } + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + if (previousCodexCliPath === undefined) delete process.env.CODEX_CLI_PATH; + else process.env.CODEX_CLI_PATH = previousCodexCliPath; + resetCodexRuntimeResolveCacheForTests(); + rmSync(root, { recursive: true, force: true }); +}); + +test("convergence renders account-qualified rows and preserves only non-generated foreign rows", async () => { + writeCatalog([ + nativeEntry(), + accountEntry("stale-selector"), + foreignEntry(), + { + ...foreignEntry(), + slug: "removed-provider/ghost", + description: "Routed via opencodex → removed-provider (removed-provider).", + }, + ]); + + const catalog = await convergeCatalog(config(true, ["team/gpt-5.6-sol"])); + const models = catalog.models ?? []; + + expect(models.find(entry => entry.slug === "gpt-5.6-sol")?.visibility).toBe("hide"); + expect(models.find(entry => entry.slug === "desktop/gpt-5.6-sol")).toMatchObject({ + display_name: "desktop / 5.6 Sol", + visibility: "list", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + }); + expect(models.find(entry => entry.slug === "team/gpt-5.6-sol")).toMatchObject({ + display_name: "team / 5.6 Sol", + visibility: "hide", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + }); + expect(models.some(entry => entry.slug === "stale-selector/gpt-5.6-sol")).toBe(false); + expect(models.some(entry => entry.slug === "external/vendor-model")).toBe(true); + expect(models.some(entry => entry.slug === "removed-provider/ghost")).toBe(false); + + const serializedCatalog = JSON.stringify(catalog); + const serializedCache = readFileSync(join(codexHome, "models_cache.json"), "utf8"); + expect((JSON.parse(serializedCache) as { models?: RawEntry[] }).models).toEqual(models); + for (const privateValue of ["side-account-id", "side@example.test", "Private Side Account"]) { + expect(serializedCatalog).not.toContain(privateValue); + expect(serializedCache).not.toContain(privateValue); + } +}); + +test("disabling the picker removes generated rows, restores bare rows, and retains foreign rows", async () => { + writeCatalog([ + nativeEntry("hide"), + accountEntry("desktop"), + accountEntry("team"), + foreignEntry(), + ]); + + const catalog = await convergeCatalog(config(false)); + const models = catalog.models ?? []; + + expect(models.find(entry => entry.slug === "gpt-5.6-sol")?.visibility).toBe("list"); + expect(models.some(entry => entry.opencodex_catalog_kind === CODEX_ACCOUNT_BOUND_CATALOG_KIND)).toBe(false); + expect(models.some(entry => entry.slug === "external/vendor-model")).toBe(true); +}); + +test("convergence drops unsupported bare native rows and never qualifies them", async () => { + writeCatalog([ + nativeEntry(), + unsupportedNativeEntry(), + ]); + + const catalog = await convergeCatalog(config(true)); + const models = catalog.models ?? []; + + expect(models.some(entry => entry.slug === "gpt-legacy-unsupported")).toBe(false); + expect(models.some(entry => entry.slug === "desktop/gpt-legacy-unsupported")).toBe(false); + expect(models.some(entry => entry.slug === "team/gpt-legacy-unsupported")).toBe(false); + expect(models + .filter(entry => entry.opencodex_catalog_kind === CODEX_ACCOUNT_BOUND_CATALOG_KIND) + .every(entry => ( + typeof entry.slug === "string" && !entry.slug.endsWith("/gpt-legacy-unsupported") + ))).toBe(true); +}); + +test("convergence preserves unrelated foreign rows alongside fresh configured provider rows", async () => { + writeCatalog([ + nativeEntry(), + foreignEntry(), + ]); + const nextConfig = config(true); + nextConfig.providers.static = { + adapter: "openai-chat", + baseUrl: "https://static.example.test/v1", + liveModels: false, + models: ["fresh-model"], + }; + + const catalog = await convergeCatalog(nextConfig); + const models = catalog.models ?? []; + + expect(models.some(entry => entry.slug === "static/fresh-model")).toBe(true); + expect(models.some(entry => entry.slug === "external/vendor-model")).toBe(true); +}); + +test("convergence preserves only provider-local degraded rows", async () => { + writeCatalog([ + nativeEntry(), + generatedRoutedEntry("offline/old-live"), + generatedRoutedEntry("offline/fallback", "stale-copy"), + generatedRoutedEntry("fresh/stale"), + generatedRoutedEntry("empty/stale"), + generatedRoutedEntry("removed/ghost"), + foreignEntry(), + ]); + const nextConfig = config(false); + nextConfig.providers.offline = { + adapter: "openai-chat", + baseUrl: "https://offline.example.test/v1", + authMode: "oauth", + models: ["fallback"], + }; + nextConfig.providers.fresh = { + adapter: "openai-chat", + baseUrl: "https://fresh.example.test/v1", + liveModels: false, + models: ["current"], + }; + nextConfig.providers.empty = { + adapter: "openai-chat", + baseUrl: "https://empty.example.test/v1", + liveModels: false, + models: [], + }; + + const models = (await convergeCatalog(nextConfig)).models ?? []; + + expect(models.some(entry => entry.slug === "offline/old-live")).toBe(true); + expect(models.filter(entry => entry.slug === "offline/fallback")).toHaveLength(1); + expect(models.find(entry => entry.slug === "offline/fallback")).not.toHaveProperty("test_marker"); + expect(models.some(entry => entry.slug === "fresh/current")).toBe(true); + expect(models.some(entry => entry.slug === "fresh/stale")).toBe(false); + expect(models.some(entry => entry.slug === "empty/stale")).toBe(false); + expect(models.some(entry => entry.slug === "removed/ghost")).toBe(false); + expect(models.some(entry => entry.slug === "external/vendor-model")).toBe(true); +}); + +test("degraded preservation still honors explicit routed visibility policy", async () => { + writeCatalog([ + nativeEntry(), + generatedRoutedEntry("offline/allowed-old"), + generatedRoutedEntry("offline/disabled-old"), + generatedRoutedEntry("offline/unselected-old"), + ]); + const nextConfig = config(false, ["offline/disabled-old"]); + nextConfig.providers.offline = { + adapter: "openai-chat", + baseUrl: "https://offline.example.test/v1", + authMode: "oauth", + models: [], + selectedModels: ["allowed-old", "disabled-old"], + }; + + const models = (await convergeCatalog(nextConfig)).models ?? []; + + expect(models.some(entry => entry.slug === "offline/allowed-old")).toBe(true); + expect(models.some(entry => entry.slug === "offline/disabled-old")).toBe(false); + expect(models.some(entry => entry.slug === "offline/unselected-old")).toBe(false); +}); + +test("custom-catalog convergence reports network degradation without a fallback notice", async () => { + catalogPath = join(codexHome, "custom-catalog.json"); + writeFileSync( + join(codexHome, "config.toml"), + 'model_catalog_json = "custom-catalog.json"\n', + ); + primeCodexRuntimeFixture(); + writeCatalog([nativeEntry(), generatedRoutedEntry("offline/old-live")]); + const nextConfig = config(false); + nextConfig.providers.offline = { + adapter: "openai-chat", + baseUrl: "https://offline.example.test/v1", + authMode: "key", + apiKey: "fixture-key", + allowPrivateNetwork: true, + models: ["fallback"], + }; + markModelsFetchFailure("offline"); + const fetchSpy = spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("{}", { status: 503 })); + try { + const disposition = await convergeCatalogDisposition(nextConfig); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(disposition).toMatchObject({ + status: "committed", + degraded: true, + notices: ["provider-network"], + }); + } finally { + fetchSpy.mockRestore(); + } +}); + +test("OAuth admission degradation is auth-only and does not masquerade as a network failure", async () => { + catalogPath = join(codexHome, "custom-catalog.json"); + writeFileSync( + join(codexHome, "config.toml"), + 'model_catalog_json = "custom-catalog.json"\n', + ); + primeCodexRuntimeFixture(); + writeCatalog([nativeEntry(), generatedRoutedEntry("offline/old-live")]); + const nextConfig = config(false); + nextConfig.providers.offline = { + adapter: "openai-chat", + baseUrl: "https://offline.example.test/v1", + authMode: "oauth", + models: ["fallback"], + }; + const fetchSpy = spyOn(globalThis, "fetch"); + try { + const disposition = await convergeCatalogDisposition(nextConfig); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(disposition).toMatchObject({ + status: "committed", + degraded: true, + notices: ["provider-auth"], + }); + } finally { + fetchSpy.mockRestore(); + } +}); + +test("disabled-provider selections cannot delete a foreign row in either writer", async () => { + primeCodexRuntimeFixture(); + catalogPath = join(codexHome, "custom-catalog.json"); + writeFileSync( + join(codexHome, "config.toml"), + 'model_catalog_json = "custom-catalog.json"\n', + ); + const foreign = { ...foreignEntry(), slug: "disabled/foreign-model" }; + writeCatalog([nativeEntry(), foreign]); + const nextConfig = config(false); + nextConfig.providers.disabled = { + adapter: "openai-chat", + baseUrl: "https://disabled.example.test/v1", + disabled: true, + liveModels: false, + selectedModels: ["some-other-model"], + }; + + await convergeCatalog(nextConfig); + const convergenceBytes = readFileSync(catalogPath, "utf8"); + expect((JSON.parse(convergenceBytes) as RawCatalog).models) + .toContainEqual(expect.objectContaining({ slug: "disabled/foreign-model" })); + + expect((await syncCatalogModels(nextConfig)).catalogWritten).toBe(true); + expect(readFileSync(catalogPath, "utf8")).toBe(convergenceBytes); +}); + +test("convergence clamps native, routed, and account rows to observed runtime support", async () => { + seedObservedRuntimeSupport(); + writeCatalog([nativeEntry()]); + const nextConfig = config(true); + nextConfig.providers.static = { + adapter: "openai-chat", + baseUrl: "https://static.example.test/v1", + liveModels: false, + models: ["reasoning-model"], + modelReasoningEfforts: { + "reasoning-model": ["low", "medium", "high", "xhigh", "max", "ultra"], + }, + modelDefaultReasoningEfforts: { "reasoning-model": "ultra" }, + }; + + const catalog = await convergeCatalog(nextConfig); + const models = catalog.models ?? []; + for (const slug of [ + "gpt-5.6-sol", + "static/reasoning-model", + "desktop/gpt-5.6-sol", + "team/gpt-5.6-sol", + ]) { + const entry = models.find(model => model.slug === slug); + const efforts = (entry?.supported_reasoning_levels ?? []) as Array<{ effort?: string }>; + expect(entry).toBeDefined(); + expect(efforts.map(level => level.effort)).not.toContain("max"); + expect(efforts.map(level => level.effort)).not.toContain("ultra"); + if (typeof entry?.default_reasoning_level === "string") { + expect(efforts.some(level => level.effort === entry.default_reasoning_level)).toBe(true); + } + } + const cache = JSON.parse(readFileSync(join(codexHome, "models_cache.json"), "utf8")) as { + models?: RawEntry[]; + }; + expect(cache.models).toEqual(models); +}); + +test("generated account rows silently win freshly gathered provider collisions", async () => { + writeCatalog([nativeEntry()]); + const nextConfig = config(true); + nextConfig.providers.team = { + adapter: "openai-chat", + baseUrl: "https://team.example.test/v1", + liveModels: false, + models: ["gpt-5.6-sol"], + }; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + + try { + const catalog = await convergeCatalog(nextConfig); + const collisions = (catalog.models ?? []) + .filter(entry => entry.slug === "team/gpt-5.6-sol"); + + expect(collisions).toHaveLength(1); + expect(collisions[0]).toMatchObject({ + display_name: "team / 5.6 Sol", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + }); + expect(warn.mock.calls.some(args => ( + args.some(value => String(value).includes("account selector collision")) + ))).toBe(false); + } finally { + warn.mockRestore(); + } +}); + +test("qualified rows retain the matching installed metadata for each native model", async () => { + const gpt55Instructions = "Installed instructions unique to GPT-5.5."; + const gpt54Instructions = "Installed instructions unique to GPT-5.4."; + writeCatalog([ + nativeMetadataEntry("gpt-5.5", gpt55Instructions, 3), + nativeMetadataEntry("gpt-5.4", gpt54Instructions, 4), + ]); + + const catalog = await convergeCatalog(config(true)); + const models = catalog.models ?? []; + + expect(models.find(entry => entry.slug === "gpt-5.5")?.base_instructions).toBe(gpt55Instructions); + expect(models.find(entry => entry.slug === "gpt-5.4")?.base_instructions).toBe(gpt54Instructions); + expect(models.find(entry => entry.slug === "team/gpt-5.5")?.base_instructions).toBe(gpt55Instructions); + expect(models.find(entry => entry.slug === "team/gpt-5.4")?.base_instructions).toBe(gpt54Instructions); +}); + +test("a missing supported native is backfilled and restored when the picker is disabled", async () => { + writeCatalog([nativeEntry()]); + + const enabled = await convergeCatalog(config(true)); + expect(enabled.models?.find(entry => entry.slug === "gpt-5.5")?.visibility).toBe("hide"); + expect(enabled.models?.find(entry => entry.slug === "team/gpt-5.5")?.visibility).toBe("list"); + + const disabled = await convergeCatalog(config(false)); + expect(disabled.models?.find(entry => entry.slug === "gpt-5.5")?.visibility).toBe("list"); + expect(disabled.models?.some(entry => ( + entry.opencodex_catalog_kind === CODEX_ACCOUNT_BOUND_CATALOG_KIND + ))).toBe(false); +}); + +test("retained sync and convergence produce identical canonical bytes in either order", async () => { + catalogPath = join(codexHome, "custom-catalog.json"); + writeFileSync( + join(codexHome, "config.toml"), + 'model_catalog_json = "custom-catalog.json"\n', + ); + primeCodexRuntimeFixture(); + const seed = (): void => { + writeCatalog([ + nativeEntry(), + unsupportedNativeEntry(), + foreignBareNativeEntry(), + ]); + }; + const readBytes = (): string => readFileSync(catalogPath, "utf8"); + const expectCanonicalContent = (bytes: string, pickerEnabled: boolean): void => { + const models = (JSON.parse(bytes) as RawCatalog).models ?? []; + const slugs = models + ?.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []) ?? []; + for (const slug of NATIVE_OPENAI_MODELS) expect(slugs).toContain(slug); + expect(slugs).not.toContain("gpt-legacy-unsupported"); + expect(slugs).toContain("user-native"); + expect(models.find(entry => entry.slug === "gpt-5.6-sol")?.visibility) + .toBe(pickerEnabled ? "hide" : "list"); + expect(models.some(entry => ( + entry.opencodex_catalog_kind === CODEX_ACCOUNT_BOUND_CATALOG_KIND + ))).toBe(pickerEnabled); + if (pickerEnabled) { + expect(models.find(entry => entry.slug === "desktop/gpt-5.6-sol")).toMatchObject({ + display_name: "desktop / 5.6 Sol", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + }); + expect(models.find(entry => entry.slug === "team/gpt-5.6-sol")).toMatchObject({ + display_name: "team / 5.6 Sol", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + }); + } + }; + + for (const pickerEnabled of [false, true]) { + const nextConfig = config(pickerEnabled); + seed(); + await convergeCatalog(nextConfig); + const convergenceFirst = readBytes(); + expectCanonicalContent(convergenceFirst, pickerEnabled); + expect((await syncCatalogModels(nextConfig)).catalogWritten).toBe(true); + expect(readBytes()).toBe(convergenceFirst); + + seed(); + expect((await syncCatalogModels(nextConfig)).catalogWritten).toBe(true); + const retainedFirst = readBytes(); + expectCanonicalContent(retainedFirst, pickerEnabled); + expect(retainedFirst).toBe(convergenceFirst); + await convergeCatalog(nextConfig); + expect(readBytes()).toBe(retainedFirst); + } +}); + +test("both writers refuse an irreplaceable combo shadow when no pristine backup exists", async () => { + catalogPath = join(codexHome, "custom-catalog.json"); + writeFileSync( + join(codexHome, "config.toml"), + 'model_catalog_json = "custom-catalog.json"\n', + ); + primeCodexRuntimeFixture(); + + const withCombo = config(false); + withCombo.providers.target = { + adapter: "openai-chat", + baseUrl: "https://target.example.test/v1", + liveModels: false, + models: ["m1"], + modelContextWindows: { m1: 128_000 }, + modelInputModalities: { m1: ["text"] }, + modelReasoningEfforts: { m1: ["low", "medium"] }, + }; + withCombo.combos = { + shadow: { + alias: "user-native", + targets: [{ provider: "target", model: "m1" }], + }, + }; + const withoutCombo = structuredClone(withCombo); + delete withoutCombo.combos; + const backupPath = catalogBackupPathFor(catalogPath); + const seed = (): void => { + rmSync(backupPath, { force: true }); + writeCatalog([foreignBareNativeEntry(), foreignEntry()]); + }; + const assertUserRow = (): void => { + const models = (JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog).models ?? []; + expect(models.filter(entry => entry.slug === "user-native")).toEqual([ + expect.objectContaining({ + display_name: "User native", + description: "Managed by another catalog tool.", + }), + ]); + expect(models.some(entry => entry.slug === "external/vendor-model")).toBe(true); + expect(existsSync(backupPath)).toBe(false); + }; + + for (const writer of ["convergence", "retained"] as const) { + seed(); + if (writer === "convergence") await convergeCatalog(withCombo); + else { + saveConfig(withCombo); + expect((await syncCatalogModels(withCombo)).catalogWritten).toBe(true); + } + assertUserRow(); + + if (writer === "convergence") await convergeCatalog(withoutCombo); + else { + saveConfig(withoutCombo); + expect((await syncCatalogModels(withoutCombo)).catalogWritten).toBe(true); + } + assertUserRow(); + } +}); + +test("convergence refuses a combo shadow when every backup target is present but invalid", async () => { + catalogPath = join(codexHome, "custom-catalog.json"); + writeFileSync( + join(codexHome, "config.toml"), + 'model_catalog_json = "custom-catalog.json"\n', + ); + primeCodexRuntimeFixture(); + + const withCombo = config(false); + withCombo.providers.target = { + adapter: "openai-chat", + baseUrl: "https://target.example.test/v1", + liveModels: false, + models: ["m1"], + modelContextWindows: { m1: 128_000 }, + modelInputModalities: { m1: ["text"] }, + modelReasoningEfforts: { m1: ["low", "medium"] }, + }; + withCombo.combos = { + shadow: { + alias: "user-native", + targets: [{ provider: "target", model: "m1" }], + }, + }; + const withoutCombo = structuredClone(withCombo); + delete withoutCombo.combos; + const backupPath = catalogBackupPathFor(catalogPath); + const invalidBackup = "{ invalid catalog backup\n"; + writeCatalog([foreignBareNativeEntry()]); + writeFileSync(backupPath, invalidBackup); + + const assertPreserved = (): void => { + const models = (JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog).models ?? []; + expect(models.filter(entry => entry.slug === "user-native")).toEqual([ + expect.objectContaining({ + display_name: "User native", + description: "Managed by another catalog tool.", + }), + ]); + expect(readFileSync(backupPath, "utf8")).toBe(invalidBackup); + }; + + await convergeCatalog(withCombo); + assertPreserved(); + await convergeCatalog(withoutCombo); + assertPreserved(); +}); + +test("both writers restore pristine native priorities after featured-model transitions", async () => { + primeCodexRuntimeFixture(); + catalogPath = join(codexHome, "custom-catalog.json"); + writeFileSync( + join(codexHome, "config.toml"), + 'model_catalog_json = "custom-catalog.json"\n', + ); + const pristineModels = [ + { ...nativeEntry(), priority: 41 }, + nativeMetadataEntry("gpt-5.5", "Installed GPT-5.5 instructions.", 57), + ]; + const backupPath = catalogBackupPathFor(catalogPath); + const seed = (): void => { + writeCatalog(pristineModels); + writeFileSync(backupPath, `${JSON.stringify({ models: pristineModels }, null, 2)}\n`); + }; + const transition = async ( + first: "convergence" | "retained", + ): Promise => { + seed(); + for (const featured of [["gpt-5.5"], ["gpt-5.6-sol"], []] as const) { + const nextConfig = config(false); + nextConfig.subagentModels = [...featured]; + saveConfig(nextConfig); + if (first === "convergence") { + await convergeCatalog(nextConfig); + await syncCatalogModels(nextConfig); + } else { + await syncCatalogModels(nextConfig); + await convergeCatalog(nextConfig); + } + } + return readFileSync(catalogPath, "utf8"); + }; + + const convergenceFirst = await transition("convergence"); + const retainedFirst = await transition("retained"); + expect(retainedFirst).toBe(convergenceFirst); + const finalModels = (JSON.parse(convergenceFirst) as RawCatalog).models ?? []; + expect(finalModels.find(entry => entry.slug === "gpt-5.6-sol")?.priority).toBe(41); + expect(finalModels.find(entry => entry.slug === "gpt-5.5")?.priority).toBe(57); +}); diff --git a/tests/codex-convergence-contract.test.ts b/tests/codex-convergence-contract.test.ts index 74af96030..113bfd6c3 100644 --- a/tests/codex-convergence-contract.test.ts +++ b/tests/codex-convergence-contract.test.ts @@ -195,6 +195,23 @@ test("used process-local authority drift rejects before every catalog target wri expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); }); +test("custom catalog runtime-support drift rejects before every catalog target write", async () => { + const runtime = { command: "/tmp/codex", version: "0.146.0", source: "environment" as const }; + const customPath = join(codexHome, "custom-catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "custom-catalog.json"\n'); + writeFileSync(customPath, sourceCatalog("custom")); + setCodexRuntimeResolveCacheForTests({ runtime, failures: [] }); + setBundledCatalogCacheForTests(runtime, JSON.parse(sourceCatalog("bundled-support")) as never); + + const gathered = await candidate(); + invalidateBundledCatalogCache(); + + expect(await commitCodexCatalogCandidate(gathered, 1_000)) + .toEqual({ kind: "stale", reason: "process-local" }); + expect(readFileSync(customPath, "utf8")).toBe(sourceCatalog("custom")); + expect(existsSync(join(codexHome, "models_cache.json"))).toBe(false); +}); + test("catalog-only commit never creates the native pair or routing/history artifacts", async () => { const gathered = await candidate(); expect((await commitCodexCatalogCandidate(gathered, 1_000)).kind).toBe("committed"); diff --git a/tests/codex-filesystem-evidence.test.ts b/tests/codex-filesystem-evidence.test.ts index e36f71c4d..3f2bbe164 100644 --- a/tests/codex-filesystem-evidence.test.ts +++ b/tests/codex-filesystem-evidence.test.ts @@ -214,7 +214,7 @@ test("populated and scratch gather sessions perform zero filesystem writes", () acceptCatalogGatherSourcePath(populated, "catalog-target-selection", configPath); readCatalogGatherSource(populated, "catalog-target-selection"); acceptCatalogGatherSourcePath(populated, "active-catalog-merge", activePath); - expect(resolveCatalogSourceForGather(populated).kind).toBe("available"); + expect(resolveCatalogSourceForGather(populated, "custom").kind).toBe("available"); acceptCatalogGatherSourcePath(populated, "models-cache-fallback", join(codexHome, "missing-cache.json")); populated.readSource("models-cache-fallback"); acceptCatalogGatherSourcePath(populated, "provider-auth-selection", authPath); diff --git a/tests/codex-runtime.test.ts b/tests/codex-runtime.test.ts index 4c37be2d8..0dddd7d66 100644 --- a/tests/codex-runtime.test.ts +++ b/tests/codex-runtime.test.ts @@ -108,7 +108,7 @@ describe("observe-only Codex catalog gather caches", () => { kind: "runtime-unavailable", processLocal: { state: "unused" }, }); - expect(resolveCatalogSourceForGather(evidence)).toEqual({ + expect(resolveCatalogSourceForGather(evidence, "default")).toEqual({ kind: "catalog-unavailable", processLocal: { runtime: { state: "unused" }, @@ -163,8 +163,8 @@ describe("observe-only Codex catalog gather caches", () => { "runtime-selection": persistedRuntimeBytes(runtime.command, runtime.version), }); - const first = resolveCatalogSourceForGather(evidence); - const second = resolveCatalogSourceForGather(evidence); + const first = resolveCatalogSourceForGather(evidence, "default"); + const second = resolveCatalogSourceForGather(evidence, "default"); expect(first.kind).toBe("available"); expect(second.kind).toBe("available"); if (first.kind !== "available" || second.kind !== "available") return; @@ -183,6 +183,59 @@ describe("observe-only Codex catalog gather caches", () => { expect(second.catalog.models?.[0]?.supported_reasoning_levels).toHaveLength(1); }); + test("custom catalog content stays authoritative while bundled runtime support is observed", () => { + const runtime = { command: "/tmp/codex", version: "0.145.0", source: "environment" as const }; + resetBundledCatalogCacheForTests(); + setBundledCatalogCacheForTests(runtime, { + models: [{ + slug: "gpt-5.5", + base_instructions: "bundled metadata", + supported_reasoning_levels: [{ effort: "xhigh", description: "xhigh" }], + }], + }); + const cacheState = bundledCatalogCacheState(); + const evidence = gatherEvidence({ + "runtime-selection": persistedRuntimeBytes(runtime.command, runtime.version), + "active-catalog-merge": Buffer.from(JSON.stringify({ + models: [{ + slug: "gpt-5.5", + base_instructions: "custom metadata", + supported_reasoning_levels: [{ effort: "medium", description: "medium" }], + }], + })), + }); + + try { + const custom = resolveCatalogSourceForGather(evidence, "custom"); + expect(custom.kind).toBe("available"); + if (custom.kind !== "available") return; + expect(custom.source).toBe("active-catalog-merge"); + expect(custom.catalog.models?.[0]?.base_instructions).toBe("custom metadata"); + expect(custom.runtimeSupport.kind).toBe("available"); + if (custom.runtimeSupport.kind === "available") { + expect(custom.runtimeSupport.catalog.models?.[0]?.base_instructions) + .toBe("bundled metadata"); + } + expect(custom.processLocal).toEqual({ + runtime: { state: "unused" }, + bundledCatalog: { + state: "used", + epoch: cacheState.epoch, + valueIdentity: cacheState.valueIdentity, + }, + }); + + const bundled = resolveCatalogSourceForGather(evidence, "default"); + expect(bundled.kind).toBe("available"); + if (bundled.kind === "available") { + expect(bundled.source).toBe("bundled-catalog-template"); + expect(bundled.catalog.models?.[0]?.base_instructions).toBe("bundled metadata"); + } + } finally { + resetBundledCatalogCacheForTests(); + } + }); + test("gather consumes a persisted runtime observation and observed disk fallback", () => { resetCodexRuntimeResolveCacheForTests(); resetBundledCatalogCacheForTests(); @@ -201,11 +254,12 @@ describe("observe-only Codex catalog gather caches", () => { expect(runtime.processLocal).toEqual({ state: "unused" }); } - const source = resolveCatalogSourceForGather(evidence); + const source = resolveCatalogSourceForGather(evidence, "custom"); expect(source.kind).toBe("available"); if (source.kind === "available") { expect(source.source).toBe("active-catalog-merge"); expect(source.catalog.models?.[0]?.base_instructions).toBe("observed"); + expect(source.runtimeSupport).toEqual({ kind: "unavailable" }); expect(source.processLocal).toEqual({ runtime: { state: "unused" }, bundledCatalog: { state: "unused" }, diff --git a/tests/codex-v2-gate.test.ts b/tests/codex-v2-gate.test.ts index 830c0d3d0..edaf2594d 100644 --- a/tests/codex-v2-gate.test.ts +++ b/tests/codex-v2-gate.test.ts @@ -8,7 +8,18 @@ import { mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { readFileSync } from "node:fs"; -import { buildCatalogEntries, mergeCatalogEntriesForSync, nativeEffortClamp, shouldApplyNativeEffortClamp, type MultiAgentMode } from "../src/codex/catalog"; +import { + buildCatalogEntries, + CODEX_ACCOUNT_BOUND_CATALOG_KIND, + mergeCatalogEntriesForSync, + nativeEffortClamp, + shouldApplyNativeEffortClamp, + type MultiAgentMode, +} from "../src/codex/catalog"; +import { + buildCatalogEntriesFromObservedState, + mergeCatalogEntriesFromObservedState, +} from "../src/codex/catalog/sync"; import { getAgentsEnabled, getAgentsMaxDepth, @@ -1133,6 +1144,88 @@ describe("mock-max wire clamp (nativeEffortClamp)", () => { }); describe("3-state multi-agent mode", () => { + test("observed catalog transforms ignore ambient V2 changes and leave evidence rows untouched", () => { + const path = fixtureConfig("[features.multi_agent_v2]\nenabled = false\n"); + const oldCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = dirname(path); + try { + const buildObserved = () => buildCatalogEntriesFromObservedState({ + template: template(), + gptSlugs: ["gpt-5.5"], + goModels: [], + featured: [], + wsEnabled: false, + multiAgentMode: "default", + exactComboSlugs: new Set(), + accountSelectors: [], + multiAgentV2Enabled: true, + }); + const catalogModels = [{ + ...template(), + display_name: "GPT-5.5 observed", + supported_reasoning_levels: [{ effort: "medium", description: "medium" }], + }]; + const routedEntries = [{ + ...template(), + slug: "external/model", + display_name: "External model", + description: "Routed via opencodex → external (external).", + supported_reasoning_levels: [{ effort: "medium", description: "medium" }], + }]; + const accountBoundEntries = [{ + ...template(), + slug: "team/gpt-5.4", + display_name: "team / GPT-5.4", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + service_tier: "fast", + }]; + const originalCatalogModels = structuredClone(catalogModels); + const originalRoutedEntries = structuredClone(routedEntries); + const originalAccountBoundEntries = structuredClone(accountBoundEntries); + const mergeObserved = () => mergeCatalogEntriesFromObservedState({ + catalogModels, + baselineCatalogModels: [], + routedEntries, + baseline: new Map([["gpt-5.5", 1]]), + featured: [], + wsEnabled: false, + template: template(), + disabledModels: new Set(), + selectedModelsByProvider: new Map(), + gatheredProviderNames: new Set(), + degradedProviderNames: new Set(), + multiAgentMode: "default", + multiAgentV2Enabled: true, + exactComboSlugs: new Set(), + hasPhysicalComboProvider: false, + includeNativeOpenAi: true, + accountBoundEntries, + policy: { + nativeBackfillSlugs: ["gpt-5.5"], + unsupportedNativeEntries: "preserve", + warningPolicy: "suppress", + }, + }); + + const builtBefore = buildObserved(); + const mergedBefore = mergeObserved(); + writeFileSync(path, "[features.multi_agent_v2]\nenabled = true\n"); + const builtAfter = buildObserved(); + const mergedAfter = mergeObserved(); + + expect(builtAfter).toEqual(builtBefore); + expect(mergedAfter).toEqual(mergedBefore); + expect(builtBefore.find(entry => entry.slug === "gpt-5.5")?.multi_agent_version).toBe("v2"); + expect(mergedBefore.find(entry => entry.slug === "gpt-5.5")?.multi_agent_version).toBe("v2"); + expect(catalogModels).toEqual(originalCatalogModels); + expect(routedEntries).toEqual(originalRoutedEntries); + expect(accountBoundEntries).toEqual(originalAccountBoundEntries); + } finally { + if (oldCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = oldCodexHome; + } + }); + test("mode v1: ALL entries get multi_agent_version = v1 (overrides upstream pins)", () => { const entries = buildCatalogEntries(template(), ["gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.5"], [], [], false, "v1"); for (const e of entries) { diff --git a/tests/gather-routed-models-single-flight.test.ts b/tests/gather-routed-models-single-flight.test.ts index 273d3b97b..40055d08c 100644 --- a/tests/gather-routed-models-single-flight.test.ts +++ b/tests/gather-routed-models-single-flight.test.ts @@ -97,12 +97,22 @@ describe("gatherRoutedModels single-flight", () => { const omissionsA: ComboCatalogOmission[] = []; const omissionsB: ComboCatalogOmission[] = []; + const outcomesA: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; + const outcomesB: Array<{ provider: string; state: "authoritative" | "degraded" }> = []; await Promise.all([ - gatherRoutedModels(config, { comboOmissions: omissionsA }), - gatherRoutedModels(config, { comboOmissions: omissionsB }), + gatherRoutedModels(config, { + comboOmissions: omissionsA, + providerModelOutcomes: outcomesA, + }), + gatherRoutedModels(config, { + comboOmissions: omissionsB, + providerModelOutcomes: outcomesB, + }), ]); expect(omissionsA.some(item => item.id === "incomplete")).toBe(true); expect(omissionsB).toEqual(omissionsA); + expect(outcomesA).toEqual([{ provider: "a", state: "authoritative" }]); + expect(outcomesB).toEqual(outcomesA); }); test("distinct provider sets keep separate in-flight gathers (no slot eviction)", async () => { diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index d2f9615ab..5cdd68233 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -272,6 +272,28 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { .not.toContain("stored-side-account"); }); + test("picker visibility hides generated catalog rows without deleting routing bindings", () => { + const codexAccountNamespaces = { desktop: "@main", team: "stored-side-account" }; + const config = { + codexAccounts: [{ id: "stored-side-account", isMain: false }], + codexAccountNamespaces, + codexAccountPickerEnabled: false, + }; + + expect(visibleCodexAccountSelectors(config)).toEqual([]); + expect(accountBoundNativeModelSlugs(config, ["gpt-5.5"])).toEqual([]); + expect(config.codexAccountNamespaces).toBe(codexAccountNamespaces); + + config.codexAccountPickerEnabled = true; + expect(visibleCodexAccountSelectors(config)).toEqual(["desktop", "team"]); + + expect(visibleCodexAccountSelectors({ + codexAccounts: config.codexAccounts, + codexAccountNamespaces: {}, + codexAccountPickerEnabled: true, + })).toEqual([]); + }); + test("catalog sync flips supported natives to visibility hide and restores list on re-enable", () => { const native = nativeTemplate(); const disabledOnce = mergeCatalogEntriesForSync( diff --git a/tests/slug-codec.test.ts b/tests/slug-codec.test.ts index 6360ecd42..6c3e7431c 100644 --- a/tests/slug-codec.test.ts +++ b/tests/slug-codec.test.ts @@ -9,6 +9,7 @@ import { encodeRoutedModelId, routedSlug, slugEquals, + slugEquivalenceKey, slugsEquivalent, } from "../src/providers/slug-codec"; import { knownModelIdsForProvider, routeModel } from "../src/router"; @@ -84,6 +85,23 @@ describe("slug-codec primitives", () => { expect(slugsEquivalent("a/b", "c/b")).toBe(false); expect(slugsEquivalent("gpt-5.5", "gpt-5.5")).toBe(true); }); + + test("slugEquivalenceKey indexes exactly the same relation as slugsEquivalent", () => { + const pairs = [ + ["gpt-5.5", "gpt-5.5"], + ["gpt-5.5", "gpt-5.4"], + ["p/org/model", "p/org-model"], + ["p/a-b", "p/a/b"], + ["p/model", "q/model"], + ["/invalid", "/invalid"], + ["/invalid/a", "/invalid-a"], + ] as const; + + for (const [left, right] of pairs) { + expect(slugEquivalenceKey(left) === slugEquivalenceKey(right)) + .toBe(slugsEquivalent(left, right)); + } + }); }); describe("routeModel decode (proxy layer)", () => { From de53992efd94086cec3863bb4fed445e9292bd53 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Fri, 7 Aug 2026 07:39:06 -0400 Subject: [PATCH 2/3] fix(codex): harden catalog convergence --- src/codex/catalog/bundled.ts | 6 +- src/codex/catalog/sync.ts | 14 ++++- tests/codex-catalog.test.ts | 36 ++++++++++++ ...odex-convergence-account-selectors.test.ts | 52 +++++++++++++++++ tests/codex-runtime.test.ts | 56 +++++++++++++++++++ 5 files changed, 162 insertions(+), 2 deletions(-) diff --git a/src/codex/catalog/bundled.ts b/src/codex/catalog/bundled.ts index d80dd2f0a..f2327d5a5 100644 --- a/src/codex/catalog/bundled.ts +++ b/src/codex/catalog/bundled.ts @@ -468,7 +468,11 @@ export function resolveCatalogSourceForGather( const bytes = evidenceSession.readSource(role); if (bytes === null) continue; const catalog = parseCatalogJson(Buffer.from(bytes).toString("utf8")); - if (!catalog || !findNativeTemplate(catalog)) continue; + if (!catalog) continue; + // Custom catalogs may intentionally contain only routed rows. Keep their existing source + // priority (active, then backup/cache) without imposing the default catalog's native-template + // requirement; a valid active custom file therefore remains authoritative over stale fallbacks. + if (pathKind === "default" && !findNativeTemplate(catalog)) continue; return cloneAndDeepFreeze({ kind: "available" as const, source: role, diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 30dbd1517..f2bdf8687 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -621,6 +621,11 @@ export function mergeCatalogEntriesFromObservedState({ if (policy.warningPolicy === "emit") warnComboUnrestorableShadowOnce(slug); return false; }); + const freshExactComboEntries = new Set(admittedRoutedEntries.filter(entry => ( + isExactComboCatalogEntry(entry, exactComboSlugs) + && typeof entry.description === "string" + && entry.description.startsWith(`Routed via opencodex → ${COMBO_NAMESPACE} (`) + ))); const rank = new Map(featured.map((slug, i) => [slug, i] as const)); const freshEquivalentKeys = new Set(admittedRoutedEntries.flatMap(entry => ( typeof entry.slug === "string" ? [slugEquivalenceKey(entry.slug)] : [] @@ -752,6 +757,10 @@ export function mergeCatalogEntriesFromObservedState({ const slug = typeof entry.slug === "string" ? entry.slug : ""; if (!slug.includes("/")) return true; if (disabledModelKeys.has(slugEquivalenceKey(slug))) return false; + // Provider allowlists own provider rows, not a current combo's public alias. Exempt only an + // identity from this gather's generated combo projection: provider discovery may supply a + // spoofed `owned_by`, and persisted combo-shaped rows are not fresh authority. + if (freshExactComboEntries.has(entry)) return true; const slash = slug.indexOf("/"); const provider = slug.slice(0, slash); const selected = selectedModelKeysByProvider.get(provider); @@ -943,7 +952,10 @@ function loadCatalogForRetainedSync(path: string): RawCatalog | null { const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null; if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog; const active = readCatalog(path); - if (active && findNativeTemplate(active)) return active; + // A valid configured custom file remains the content authority even when it has no bare native + // template. The null-template builder is deliberate; a stale backup must not replace active + // custom root metadata merely because the current file contains only routed rows. + if (active && (!isDefaultCatalogPath(path) || findNativeTemplate(active))) return active; return readCatalog(catalogBackupPathFor(path)) ?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null) ?? readCatalog(activeCodexModelsCachePath()) diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index a78a64a84..1a3d59bc5 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -303,6 +303,42 @@ describe("combo catalog capability intersection", () => { } }); + test("provider allowlists do not remove a current slashed combo alias", () => { + const row = (slug: string, ownedBy: string, sourceProvider = ownedBy) => ({ + ...nativeTemplate(), + slug, + owned_by: ownedBy, + description: `Routed via opencodex → ${sourceProvider} (${ownedBy}).`, + input_modalities: ["text"], + }); + const merged = mergeObservedForTest({ + catalogModels: [row("vendor/persisted", "combo")], + routedEntries: [ + row("vendor/flash", "combo"), + row("vendor/allowed", "vendor"), + row("vendor/blocked", "vendor"), + row("vendor/spoofed", "combo", "vendor"), + row("vendor/stale", "combo"), + ], + selectedModelsByProvider: new Map([["vendor", new Set(["allowed"])]]), + gatheredProviderNames: new Set(["vendor"]), + degradedProviderNames: new Set(["vendor"]), + exactComboSlugs: new Set([ + "vendor/flash", + "vendor/persisted", + "vendor/spoofed", + ]), + hasPhysicalComboProvider: true, + }); + + expect(merged.map(entry => entry.slug)).toContain("vendor/flash"); + expect(merged.map(entry => entry.slug)).toContain("vendor/allowed"); + expect(merged.map(entry => entry.slug)).not.toContain("vendor/blocked"); + expect(merged.map(entry => entry.slug)).not.toContain("vendor/spoofed"); + expect(merged.map(entry => entry.slug)).not.toContain("vendor/persisted"); + expect(merged.map(entry => entry.slug)).not.toContain("vendor/stale"); + }); + test("preserves exact combo capabilities under an alias", () => { const alias = "deepseek-v4-flash"; const model = deriveComboCatalogModel( diff --git a/tests/codex-convergence-account-selectors.test.ts b/tests/codex-convergence-account-selectors.test.ts index 17c87e0e1..5f146fdca 100644 --- a/tests/codex-convergence-account-selectors.test.ts +++ b/tests/codex-convergence-account-selectors.test.ts @@ -462,6 +462,58 @@ test("custom-catalog convergence reports network degradation without a fallback } }); +test("routed-only custom catalogs remain authoritative across convergence and retained sync", async () => { + catalogPath = join(codexHome, "custom-catalog.json"); + writeFileSync( + join(codexHome, "config.toml"), + 'model_catalog_json = "custom-catalog.json"\n', + ); + primeCodexRuntimeFixture(); + writeFileSync(catalogPath, `${JSON.stringify({ + root_marker: "active-custom", + models: [generatedRoutedEntry("static/old")], + }, null, 2)}\n`); + const nextConfig: OcxConfig = { + port: 10100, + defaultProvider: "static", + providers: { + static: { + adapter: "openai-chat", + baseUrl: "https://static.example.test/v1", + liveModels: false, + models: ["fresh"], + }, + }, + }; + + const first = await convergeCatalogDisposition(nextConfig); + expect(first).toMatchObject({ status: "committed", notices: [] }); + let catalog = JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog; + expect(catalog.root_marker).toBe("active-custom"); + expect(catalog.models?.some(entry => entry.slug === "static/old")).toBe(false); + expect(catalog.models?.some(entry => entry.slug === "static/fresh")).toBe(true); + expect(catalog.models?.some(entry => ( + typeof entry.slug === "string" && !entry.slug.includes("/") + ))).toBe(false); + expect(existsSync(catalogBackupPathFor(catalogPath))).toBe(false); + + writeFileSync(catalogBackupPathFor(catalogPath), `${JSON.stringify({ + root_marker: "stale-backup", + models: [nativeEntry()], + }, null, 2)}\n`); + nextConfig.providers.static!.models = ["newer"]; + const second = await convergeCatalogDisposition(nextConfig); + expect(second).toMatchObject({ status: "committed", notices: [] }); + catalog = JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog; + expect(catalog.root_marker).toBe("active-custom"); + expect(catalog.models?.some(entry => entry.slug === "static/fresh")).toBe(false); + expect(catalog.models?.some(entry => entry.slug === "static/newer")).toBe(true); + + const convergenceBytes = readFileSync(catalogPath, "utf8"); + expect((await syncCatalogModels(nextConfig)).catalogWritten).toBe(true); + expect(readFileSync(catalogPath, "utf8")).toBe(convergenceBytes); +}); + test("OAuth admission degradation is auth-only and does not masquerade as a network failure", async () => { catalogPath = join(codexHome, "custom-catalog.json"); writeFileSync( diff --git a/tests/codex-runtime.test.ts b/tests/codex-runtime.test.ts index 0dddd7d66..8331347af 100644 --- a/tests/codex-runtime.test.ts +++ b/tests/codex-runtime.test.ts @@ -236,6 +236,62 @@ describe("observe-only Codex catalog gather caches", () => { } }); + test("a routed-only active custom catalog stays authoritative over a native backup", () => { + const runtime = { command: "/tmp/codex", version: "0.145.0", source: "environment" as const }; + resetBundledCatalogCacheForTests(); + setBundledCatalogCacheForTests(runtime, { + models: [{ + slug: "gpt-5.5", + base_instructions: "bundled runtime metadata", + }], + }); + const evidence = gatherEvidence({ + "runtime-selection": persistedRuntimeBytes(runtime.command, runtime.version), + "active-catalog-merge": Buffer.from(JSON.stringify({ + root_marker: "active-custom", + models: [{ + slug: "vendor/routed-only", + base_instructions: "active routed metadata", + }], + })), + "hashed-backup-fallback": Buffer.from(JSON.stringify({ + root_marker: "stale-backup", + models: [{ + slug: "gpt-5.5", + base_instructions: "backup native metadata", + }], + })), + }); + + try { + const source = resolveCatalogSourceForGather(evidence, "custom"); + expect(source.kind).toBe("available"); + if (source.kind !== "available") return; + expect(source.source).toBe("active-catalog-merge"); + expect(source.catalog.root_marker).toBe("active-custom"); + expect(source.catalog.models?.map(entry => entry.slug)).toEqual(["vendor/routed-only"]); + expect(source.runtimeSupport.kind).toBe("available"); + + const fallback = resolveCatalogSourceForGather(gatherEvidence({ + "runtime-selection": persistedRuntimeBytes(runtime.command, runtime.version), + "hashed-backup-fallback": Buffer.from(JSON.stringify({ + root_marker: "routed-backup", + models: [{ + slug: "vendor/backup-only", + base_instructions: "routed backup metadata", + }], + })), + }), "custom"); + expect(fallback.kind).toBe("available"); + if (fallback.kind === "available") { + expect(fallback.source).toBe("hashed-backup-fallback"); + expect(fallback.catalog.root_marker).toBe("routed-backup"); + } + } finally { + resetBundledCatalogCacheForTests(); + } + }); + test("gather consumes a persisted runtime observation and observed disk fallback", () => { resetCodexRuntimeResolveCacheForTests(); resetBundledCatalogCacheForTests(); From 92ec9d6880d5af768f2f517dfa6a5ccc9ea7a140 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Sat, 8 Aug 2026 03:20:48 -0400 Subject: [PATCH 3/3] fix(codex): migrate legacy custom catalog ownership --- src/codex/catalog/parsing.ts | 4 +- src/codex/catalog/sync.ts | 33 +++- src/codex/convergence.ts | 2 + src/codex/custom-model-catalog-migration.ts | 176 ++++++++++++++++++ src/config.ts | 29 ++- src/types.ts | 6 + tests/codex-catalog.test.ts | 155 ++++++++++++++- ...odex-convergence-account-selectors.test.ts | 63 ++++++- tests/codex-v2-gate.test.ts | 1 + tests/config-user-edits.test.ts | 115 ++++++++++++ tests/custom-model-catalog-migration.test.ts | 135 ++++++++++++++ 11 files changed, 707 insertions(+), 12 deletions(-) create mode 100644 src/codex/custom-model-catalog-migration.ts create mode 100644 tests/custom-model-catalog-migration.test.ts diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index afb9c3d0f..a4cc9e073 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -87,6 +87,8 @@ export function isDefaultCatalogPath(path: string): boolean { /** Stable nonsemantic ownership marker for rows projected from config.customModels. */ export const CODEX_CUSTOM_MODEL_CATALOG_KIND = "custom-model-v1"; +/** A formerly ambiguous slug was authoritatively observed as an ordinary provider row. */ +export const CODEX_PROVIDER_MODEL_CATALOG_KIND = "provider-model-v1"; export interface CatalogModel { id: string; @@ -117,7 +119,7 @@ export interface CatalogModel { /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */ capabilities?: string[]; /** OpenCodex-only catalog ownership marker; Codex ignores the serialized extension field. */ - catalogKind?: typeof CODEX_CUSTOM_MODEL_CATALOG_KIND; + catalogKind?: typeof CODEX_CUSTOM_MODEL_CATALOG_KIND | typeof CODEX_PROVIDER_MODEL_CATALOG_KIND; } export type RawEntry = Record; diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 1a86e9ba3..1eeb890ff 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -4,6 +4,7 @@ import { existsSync, readFileSync } from "node:fs"; import { delimiter, dirname, join, resolve } from "node:path"; import { expandUserPath, loadConfig, readConfigDiagnostics, websocketsEnabled } from "../../config"; import { shouldSyncCodexOnStart } from "../desired-state"; +import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration"; import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, getCodexHome, readRootTomlString, resolveCodexConfigPath } from "../paths"; import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache"; import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth"; @@ -30,7 +31,7 @@ import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { CODEX_CUSTOM_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; +import { CODEX_CUSTOM_MODEL_CATALOG_KIND, CODEX_PROVIDER_MODEL_CATALOG_KIND, activeCodexModelsCachePath, applyCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { @@ -527,6 +528,7 @@ export interface ObservedCatalogMergeInput { readonly selectedModelsByProvider: ReadonlyMap>; readonly gatheredProviderNames: ReadonlySet; readonly degradedProviderNames: ReadonlySet; + readonly legacyCustomModelSlugs: ReadonlySet; readonly multiAgentMode: MultiAgentMode; readonly multiAgentV2Enabled: boolean; readonly exactComboSlugs: ReadonlySet; @@ -554,6 +556,7 @@ export function mergeCatalogEntriesFromObservedState({ selectedModelsByProvider, gatheredProviderNames, degradedProviderNames, + legacyCustomModelSlugs, multiAgentMode, multiAgentV2Enabled, exactComboSlugs, @@ -571,6 +574,9 @@ export function mergeCatalogEntriesFromObservedState({ const detachedAccountBoundEntries = accountBoundEntries .map(entry => structuredClone(entry) as RawEntry); const disabledModelKeys = new Set([...disabledModels].map(slugEquivalenceKey)); + const legacyCustomModelKeys = new Set( + [...legacyCustomModelSlugs].map(slugEquivalenceKey), + ); const selectedModelKeysByProvider = new Map([...selectedModelsByProvider].map(([provider, models]) => ( [provider, new Set([...models].map(model => slugEquivalenceKey(routedSlug(provider, model))))] as const ))); @@ -621,6 +627,18 @@ export function mergeCatalogEntriesFromObservedState({ if (policy.warningPolicy === "emit") warnComboUnrestorableShadowOnce(slug); return false; }); + // A fresh non-custom row authoritatively resolves a historically ambiguous slug as a normal + // provider model. Persist that classification so the durable deletion evidence cannot remove + // the legitimate row during a later degraded refresh. + for (const entry of admittedRoutedEntries) { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + if (!slug + || entry.opencodex_catalog_kind !== undefined + || entry.owned_by === COMBO_NAMESPACE + || !isOcxAuthoredRoutedEntry(entry) + || !legacyCustomModelKeys.has(slugEquivalenceKey(slug))) continue; + entry.opencodex_catalog_kind = CODEX_PROVIDER_MODEL_CATALOG_KIND; + } const freshExactComboEntries = new Set(admittedRoutedEntries.filter(entry => ( isExactComboCatalogEntry(entry, exactComboSlugs) && typeof entry.description === "string" @@ -742,6 +760,13 @@ export function mergeCatalogEntriesFromObservedState({ // Current custom rows are always regenerated from config, even while provider discovery is // degraded. A marked row absent from the fresh projection is therefore an intentional delete. if (entry.opencodex_catalog_kind === CODEX_CUSTOM_MODEL_CATALOG_KIND) return false; + // Before custom rows had a marker, a config deletion could otherwise be mistaken for a + // provider outage. Only explicit save-boundary evidence may classify an unmarked OpenCodex + // row; foreign and future-marked rows fail closed and remain preserved. + if (entry.opencodex_catalog_kind === undefined + && entry.owned_by !== COMBO_NAMESPACE + && isOcxAuthoredRoutedEntry(entry) + && legacyCustomModelKeys.has(slugEquivalenceKey(slug))) return false; const provider = slug.slice(0, slug.indexOf("/")); if (gatheredProviderNames.has(provider)) { // A provider-local degraded observation preserves only that namespace. Authoritative empty @@ -844,7 +869,8 @@ export function mergeCatalogEntriesFromObservedState({ for (const entry of versionedEntries) { const kind = entry.opencodex_catalog_kind; if (trustedAccountBoundNativeCatalogSlug(entry) === undefined - && kind !== CODEX_CUSTOM_MODEL_CATALOG_KIND) continue; + && kind !== CODEX_CUSTOM_MODEL_CATALOG_KIND + && kind !== CODEX_PROVIDER_MODEL_CATALOG_KIND) continue; // Canonicalize extension-field order after every normalizer. This keeps an unchanged catalog // byte-idempotent whether an owned row was freshly built or retained from the prior pass. delete entry.opencodex_catalog_kind; @@ -869,6 +895,7 @@ export function mergeCatalogEntriesForSync( hasPhysicalComboProvider = false, includeNativeOpenAi = true, accountBoundEntries: readonly RawEntry[] = [], + legacyCustomModelSlugs: ReadonlySet = new Set(), ): RawEntry[] { // Retained for source compatibility with the original helper contract. Raw provider ids must // not suppress same-named native rows; actual admitted combo entries own that decision now. @@ -895,6 +922,7 @@ export function mergeCatalogEntriesForSync( selectedModelsByProvider: new Map(), gatheredProviderNames: effectiveGatheredProviderNames, degradedProviderNames: new Set(), + legacyCustomModelSlugs, multiAgentMode, multiAgentV2Enabled: isMultiAgentV2Enabled(), exactComboSlugs, @@ -1168,6 +1196,7 @@ function writeRetainedCatalogSync({ selectedModelsByProvider, gatheredProviderNames, degradedProviderNames, + legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config), multiAgentMode, multiAgentV2Enabled, exactComboSlugs, diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index ba12f4cda..d5710f925 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -5,6 +5,7 @@ import { COMBO_NAMESPACE } from "../combos"; import { getAuthStorePath } from "../oauth/store"; import type { OcxConfig } from "../types"; import { captureCatalogAdmissionSnapshot } from "./catalog-admission"; +import { legacyCustomModelCatalogSlugs } from "./custom-model-catalog-migration"; import { type CatalogGatherPathKind, type CatalogSourceForGather, @@ -268,6 +269,7 @@ function prepareCatalog( selectedModelsByProvider, gatheredProviderNames, degradedProviderNames, + legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config), multiAgentMode, multiAgentV2Enabled, exactComboSlugs, diff --git a/src/codex/custom-model-catalog-migration.ts b/src/codex/custom-model-catalog-migration.ts new file mode 100644 index 000000000..48caedbcb --- /dev/null +++ b/src/codex/custom-model-catalog-migration.ts @@ -0,0 +1,176 @@ +import { routedSlug, slugEquivalenceKey } from "../providers/slug-codec"; +import type { OcxConfig } from "../types"; + +const MIGRATION_FIELD = "customModelCatalogMigration"; +const MIGRATION_VERSION = 1; + +type ConfigRecord = Record; + +interface SupportedMigrationState { + readonly record: ConfigRecord; + readonly legacyOwnedSlugs: readonly string[]; +} + +type ParsedMigrationState = + | { readonly kind: "absent" } + | { readonly kind: "supported"; readonly state: SupportedMigrationState } + | { readonly kind: "unsupported"; readonly value: unknown }; + +type CustomModelSlugSet = + | { readonly kind: "valid"; readonly byKey: ReadonlyMap } + | { readonly kind: "invalid" }; + +function configRecord(value: unknown): ConfigRecord | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as ConfigRecord + : null; +} + +function canonicalRoutedSlug(value: unknown): string | null { + if (typeof value !== "string") return null; + const slug = value.trim(); + const slash = slug.indexOf("/"); + if (slash <= 0 || slash === slug.length - 1) return null; + const provider = slug.slice(0, slash).trim(); + const modelId = slug.slice(slash + 1).trim(); + if (!provider || !modelId || provider.includes("/")) return null; + return routedSlug(provider, modelId); +} + +function canonicalSlugMap(values: readonly unknown[]): ReadonlyMap | null { + const byKey = new Map(); + for (const value of values) { + const slug = canonicalRoutedSlug(value); + if (slug === null) return null; + byKey.set(slugEquivalenceKey(slug), slug); + } + return byKey; +} + +function parseMigrationState(config: unknown): ParsedMigrationState { + const record = configRecord(config); + if (!record || !Object.hasOwn(record, MIGRATION_FIELD)) return { kind: "absent" }; + const value = record[MIGRATION_FIELD]; + const state = configRecord(value); + if (!state || state.version !== MIGRATION_VERSION || !Array.isArray(state.legacyOwnedSlugs)) { + return { kind: "unsupported", value }; + } + const slugs = canonicalSlugMap(state.legacyOwnedSlugs); + if (slugs === null) return { kind: "unsupported", value }; + return { + kind: "supported", + state: { + record: state, + legacyOwnedSlugs: [...slugs.values()].sort(), + }, + }; +} + +function customModelSlugs(config: unknown): CustomModelSlugSet { + const record = configRecord(config); + if (!record || record.customModels === undefined) { + return { kind: "valid", byKey: new Map() }; + } + if (!Array.isArray(record.customModels)) return { kind: "invalid" }; + const byKey = new Map(); + for (const value of record.customModels) { + const custom = configRecord(value); + if (!custom || typeof custom.provider !== "string" || typeof custom.modelId !== "string") { + return { kind: "invalid" }; + } + const provider = custom.provider.trim(); + const modelId = custom.modelId.trim(); + if (!provider || !modelId || provider.includes("/")) return { kind: "invalid" }; + const slug = routedSlug(provider, modelId); + byKey.set(slugEquivalenceKey(slug), slug); + } + return { kind: "valid", byKey }; +} + +function copyMigrationField(target: OcxConfig, source: OcxConfig): void { + const targetRecord = target as unknown as ConfigRecord; + const sourceRecord = source as unknown as ConfigRecord; + if (Object.hasOwn(sourceRecord, MIGRATION_FIELD)) { + targetRecord[MIGRATION_FIELD] = structuredClone(sourceRecord[MIGRATION_FIELD]); + } else { + delete targetRecord[MIGRATION_FIELD]; + } +} + +/** + * Project one-time legacy custom-model ownership onto a whole-config write. + * + * The persisted pre-write config is the only authority for pre-marker ownership. + * Unknown future/malformed state is carried forward byte-for-value and grants no + * deletion authority; an older binary must never reinterpret or erase it. + */ +export function projectCustomModelCatalogMigration( + persistedConfig: unknown, + candidateConfig: OcxConfig, +): OcxConfig { + const persistedState = parseMigrationState(persistedConfig); + const candidateState = parseMigrationState(candidateConfig); + const projected = { ...candidateConfig } as OcxConfig; + const projectedRecord = projected as unknown as ConfigRecord; + + if (persistedState.kind === "unsupported") { + projectedRecord[MIGRATION_FIELD] = structuredClone(persistedState.value); + return projected; + } + if (persistedState.kind === "absent" && candidateState.kind === "unsupported") { + projectedRecord[MIGRATION_FIELD] = structuredClone(candidateState.value); + return projected; + } + + const legacyByKey = new Map(); + const addLegacy = (slugs: readonly string[]): void => { + for (const slug of slugs) legacyByKey.set(slugEquivalenceKey(slug), slug); + }; + if (persistedState.kind === "supported") addLegacy(persistedState.state.legacyOwnedSlugs); + if (candidateState.kind === "supported") addLegacy(candidateState.state.legacyOwnedSlugs); + + const persistedCustom = customModelSlugs(persistedConfig); + const candidateCustom = customModelSlugs(candidateConfig); + // Only an absent persisted version means these rows can predate the ownership marker. + // Never add candidate-only or post-version models: those are born with custom-model-v1. + if (persistedState.kind === "absent" && persistedCustom.kind === "valid") { + for (const [key, slug] of persistedCustom.byKey) legacyByKey.set(key, slug); + } + + // Establish an empty v1 on the first custom-model add too: the cutover, not list + // non-emptiness, proves that candidate-only models can never be inferred as legacy. + // Unrelated saves for users who never configured custom models remain untouched. + const shouldVersion = persistedState.kind === "supported" + || candidateState.kind === "supported" + || (persistedState.kind === "absent" + && ((persistedCustom.kind === "valid" && persistedCustom.byKey.size > 0) + || (candidateCustom.kind === "valid" && candidateCustom.byKey.size > 0))); + if (!shouldVersion) { + delete projectedRecord[MIGRATION_FIELD]; + return projected; + } + + const priorRecord = persistedState.kind === "supported" ? persistedState.state.record : {}; + const nextRecord = candidateState.kind === "supported" ? candidateState.state.record : {}; + projectedRecord[MIGRATION_FIELD] = { + ...structuredClone(priorRecord), + ...structuredClone(nextRecord), + version: MIGRATION_VERSION, + legacyOwnedSlugs: [...legacyByKey.values()].sort(), + }; + return projected; +} + +/** Copy only the internal migration state after the projected write succeeds. */ +export function adoptCustomModelCatalogMigration( + target: OcxConfig, + projected: OcxConfig, +): void { + copyMigrationField(target, projected); +} + +/** Canonical slugs that may classify old, unmarked OpenCodex custom rows. */ +export function legacyCustomModelCatalogSlugs(config: OcxConfig): ReadonlySet { + const state = parseMigrationState(config); + return new Set(state.kind === "supported" ? state.state.legacyOwnedSlugs : []); +} diff --git a/src/config.ts b/src/config.ts index 3d0d1a239..15e99f28b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -29,6 +29,10 @@ import { } from "./codex/account-namespace-match"; import { isCodexAccountPriorityKey } from "./codex/account-priority"; import { UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD } from "./codex/upstream-host-health"; +import { + adoptCustomModelCatalogMigration, + projectCustomModelCatalogMigration, +} from "./codex/custom-model-catalog-migration"; import { parseAccountPriority } from "./codex/pool-rotation"; import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types"; import { routingProfileIssues } from "./routing/profile"; @@ -2381,7 +2385,9 @@ export function saveConfig(config: OcxConfig): void { // Keep the real-home assertion ahead of even lock-directory preparation. assertNotRealHomeUnderTest(getConfigDir()); withConfigMutationLockSync(() => { - if (persistConfigUnlocked(config)) bumpGenerationForCooperatingConfigWrite(); + const projected = projectCustomModelCatalogMigration(readRawConfigJson(), config); + if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite(); + adoptCustomModelCatalogMigration(config, projected); }); } @@ -2462,7 +2468,11 @@ export function mutatePersistedConfig( continue; } - if (persistConfigUnlocked(confirmedConfig)) bumpGenerationForCooperatingConfigWrite(); + const projected = projectCustomModelCatalogMigration( + commitBase.diagnostics.config, + confirmedConfig, + ); + if (persistConfigUnlocked(projected)) bumpGenerationForCooperatingConfigWrite(); return { status: "committed", value: confirmed.value }; } return { status: "unavailable", reason: "conflict" }; @@ -2700,9 +2710,9 @@ function readPersistedServerBinding( export function saveConfigPreservingClaudeCode(config: OcxConfig): void { withConfigMutationLockSync(() => { const bindingBaseline = persistedLiveServerBinding.get(config); - const onDisk = claudeCodeBaseline.has(config) || bindingBaseline - ? readRawConfigJson() - : undefined; + // One authoritative pre-write read feeds both the live-config reconciliation and + // custom-model deletion migration. A second read could observe different bytes. + const onDisk = readRawConfigJson(); if (claudeCodeBaseline.has(config)) { if (onDisk !== undefined) { const baseline = claudeCodeBaseline.get(config); @@ -2714,18 +2724,23 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void { } } } + const projectedConfig = projectCustomModelCatalogMigration( + onDisk, + config, + ); const persistedBinding = bindingBaseline && onDisk ? readPersistedServerBinding(onDisk, bindingBaseline) : bindingBaseline; if (persistedBinding) { - const persistedConfig: OcxConfig = { ...config, port: persistedBinding.port }; + const persistedConfig: OcxConfig = { ...projectedConfig, port: persistedBinding.port }; if (persistedBinding.hostname === undefined) delete persistedConfig.hostname; else persistedConfig.hostname = persistedBinding.hostname; if (persistConfigUnlocked(persistedConfig)) bumpGenerationForCooperatingConfigWrite(); persistedLiveServerBinding.set(config, persistedBinding); } else { - if (persistConfigUnlocked(config)) bumpGenerationForCooperatingConfigWrite(); + if (persistConfigUnlocked(projectedConfig)) bumpGenerationForCooperatingConfigWrite(); } + adoptCustomModelCatalogMigration(config, projectedConfig); if (claudeCodeBaseline.has(config)) { claudeCodeBaseline.set(config, structuredClone(config.claudeCode)); } diff --git a/src/types.ts b/src/types.ts index 481c15a4b..ed47ec30a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -700,6 +700,12 @@ export interface OcxConfig { disabledModels?: string[]; /** 사용자가 대시보드에서 직접 추가한 커스텀 모델 목록. */ customModels?: OcxCustomModel[]; + /** + * Internal, versioned evidence for reconciling custom-model deletions with + * pre-marker Codex catalog rows. Consumers must parse this defensively so a + * future state written by a newer binary survives older whole-config saves. + */ + customModelCatalogMigration?: unknown; /** * Shadow call intercept: redirect Codex's hard-coded helper calls (title generation, * commit messages, skill orchestration) to a user-chosen model. Default intercepted diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 7169ecccb..303084392 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -3,7 +3,10 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { augmentRoutedModelsWithMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, CODEX_ACCOUNT_BOUND_CATALOG_KIND, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, shouldExposeRoutedModel } from "../src/codex/catalog"; -import { CODEX_CUSTOM_MODEL_CATALOG_KIND } from "../src/codex/catalog/parsing"; +import { + CODEX_CUSTOM_MODEL_CATALOG_KIND, + CODEX_PROVIDER_MODEL_CATALOG_KIND, +} from "../src/codex/catalog/parsing"; import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; import { CURSOR_STATIC_MODELS, @@ -23,6 +26,7 @@ import { type ProviderModelDiscoveryStatus, } from "../src/codex/model-cache"; import type { OcxConfig } from "../src/types"; +import { COMBO_NAMESPACE } from "../src/combos"; import type { NormalizedComboConfig } from "../src/combos/types"; import { enrichProviderFromRegistry } from "../src/providers/derive"; import { enrichProviderFromCatalog } from "../src/oauth/key-providers"; @@ -1252,7 +1256,9 @@ describe("configured CatalogModel displayName -> catalog display_name", () => { clearModelCache("custom-provider"); } }); +}); +describe("legacy custom-model catalog ownership", () => { test("degraded provider preservation cannot resurrect a deleted custom model", () => { const staleCustom = buildCatalogEntries(nativeTemplate(), [], [{ provider: "custom-provider", @@ -1268,6 +1274,152 @@ describe("configured CatalogModel displayName -> catalog display_name", () => { expect(merged.some(entry => entry.slug === "custom-provider/removed-model")).toBe(false); }); + + test("deletion evidence removes only an old unmarked OpenCodex custom row", () => { + const staleUnmarked = buildCatalogEntries(nativeTemplate(), [], [{ + provider: "custom-provider", + id: "removed-model", + }]); + const foreignSameSlug = { + ...staleUnmarked[0], + description: "Managed by another catalog tool.", + }; + const input = { + routedEntries: [], + gatheredProviderNames: new Set(["custom-provider"]), + degradedProviderNames: new Set(["custom-provider"]), + legacyCustomModelSlugs: new Set(["custom-provider/removed-model"]), + }; + + const removed = mergeObservedForTest({ catalogModels: staleUnmarked, ...input }); + expect(removed.some(entry => entry.slug === "custom-provider/removed-model")).toBe(false); + + const preserved = mergeObservedForTest({ catalogModels: [foreignSameSlug], ...input }); + expect(preserved).toContainEqual(expect.objectContaining({ + slug: "custom-provider/removed-model", + description: "Managed by another catalog tool.", + })); + }); + + test("fresh provider rediscovery acknowledges a removed slug before a later outage", () => { + const freshProvider = buildCatalogEntries(nativeTemplate(), [], [{ + provider: "custom-provider", + id: "removed-model", + }]); + const evidence = new Set(["custom-provider/removed-model"]); + const rediscovered = mergeObservedForTest({ + catalogModels: [], + routedEntries: freshProvider, + gatheredProviderNames: new Set(["custom-provider"]), + legacyCustomModelSlugs: evidence, + }); + const acknowledged = rediscovered.find(entry => ( + entry.slug === "custom-provider/removed-model" + )); + expect(acknowledged?.opencodex_catalog_kind).toBe(CODEX_PROVIDER_MODEL_CATALOG_KIND); + + const degraded = mergeObservedForTest({ + catalogModels: rediscovered, + routedEntries: [], + gatheredProviderNames: new Set(["custom-provider"]), + degradedProviderNames: new Set(["custom-provider"]), + legacyCustomModelSlugs: evidence, + }); + expect(degraded).toContainEqual(expect.objectContaining({ + slug: "custom-provider/removed-model", + opencodex_catalog_kind: CODEX_PROVIDER_MODEL_CATALOG_KIND, + })); + }); + + test("fresh custom ownership wins historical evidence and stays deletion-authoritative", () => { + const freshCustom = buildCatalogEntries(nativeTemplate(), [], [{ + provider: "custom-provider", + id: "removed-model", + catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, + }]); + const evidence = new Set(["custom-provider/removed-model"]); + const readded = mergeObservedForTest({ + catalogModels: [], + routedEntries: freshCustom, + gatheredProviderNames: new Set(["custom-provider"]), + legacyCustomModelSlugs: evidence, + }); + expect(readded).toContainEqual(expect.objectContaining({ + slug: "custom-provider/removed-model", + opencodex_catalog_kind: CODEX_CUSTOM_MODEL_CATALOG_KIND, + })); + + const deletedAgain = mergeObservedForTest({ + catalogModels: readded, + routedEntries: [], + gatheredProviderNames: new Set(["custom-provider"]), + degradedProviderNames: new Set(["custom-provider"]), + legacyCustomModelSlugs: evidence, + }); + expect(deletedAgain.some(entry => entry.slug === "custom-provider/removed-model")).toBe(false); + }); + + test("unknown catalog kinds fail closed under legacy deletion evidence", () => { + const unknownKind = { + ...buildCatalogEntries(nativeTemplate(), [], [{ + provider: "custom-provider", + id: "removed-model", + }])[0], + opencodex_catalog_kind: "future-model-v2", + }; + const merged = mergeObservedForTest({ + catalogModels: [unknownKind], + routedEntries: [], + gatheredProviderNames: new Set(["custom-provider"]), + degradedProviderNames: new Set(["custom-provider"]), + legacyCustomModelSlugs: new Set(["custom-provider/removed-model"]), + }); + expect(merged).toContainEqual(expect.objectContaining({ + slug: "custom-provider/removed-model", + opencodex_catalog_kind: "future-model-v2", + })); + }); + + test("legacy evidence cannot claim account-selector or combo rows", () => { + const account = { + ...nativeTemplate(), + slug: "team/gpt-5.5", + display_name: "team / GPT-5.5", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + }; + const accountMerged = mergeObservedForTest({ + catalogModels: [account], + routedEntries: [], + accountBoundEntries: [account], + legacyCustomModelSlugs: new Set(["team/gpt-5.5"]), + }); + expect(accountMerged).toContainEqual(expect.objectContaining({ + slug: "team/gpt-5.5", + opencodex_catalog_kind: CODEX_ACCOUNT_BOUND_CATALOG_KIND, + })); + + const combo = { + ...buildCatalogEntries(nativeTemplate(), [], [{ + provider: "custom-provider", + id: "removed-model", + }])[0], + owned_by: COMBO_NAMESPACE, + input_modalities: ["text"], + }; + const comboMerged = mergeObservedForTest({ + catalogModels: [combo], + routedEntries: [], + gatheredProviderNames: new Set(["custom-provider"]), + degradedProviderNames: new Set(["custom-provider"]), + exactComboSlugs: new Set(["custom-provider/removed-model"]), + hasPhysicalComboProvider: true, + legacyCustomModelSlugs: new Set(["custom-provider/removed-model"]), + }); + expect(comboMerged).toContainEqual(expect.objectContaining({ + slug: "custom-provider/removed-model", + owned_by: COMBO_NAMESPACE, + })); + }); }); test("a custom row inherits provider reasoning metadata from the provider-derived row it replaces (#962)", async () => { @@ -1385,6 +1537,7 @@ function mergeObservedForTest( selectedModelsByProvider: new Map(), gatheredProviderNames: new Set(), degradedProviderNames: new Set(), + legacyCustomModelSlugs: new Set(), multiAgentMode: "default", multiAgentV2Enabled: false, exactComboSlugs: new Set(), diff --git a/tests/codex-convergence-account-selectors.test.ts b/tests/codex-convergence-account-selectors.test.ts index 5f146fdca..e58f7270d 100644 --- a/tests/codex-convergence-account-selectors.test.ts +++ b/tests/codex-convergence-account-selectors.test.ts @@ -34,7 +34,7 @@ import { resolveCodexCatalogSerializationDatabasePath, resolveEffectiveUserIdentity, } from "../src/codex/user-identity"; -import { saveConfig } from "../src/config"; +import { getConfigPath, saveConfig } from "../src/config"; import { CODEX_FORWARD_BASE_URL } from "../src/providers/openai-tiers"; import type { OcxConfig } from "../src/types"; import { setBundledCatalogCacheForTests } from "../src/codex/catalog/bundled"; @@ -43,6 +43,7 @@ import { setCodexRuntimeResolveCacheForTests, } from "../src/codex/runtime"; import { markModelsFetchFailure } from "../src/codex/model-cache"; +import { legacyCustomModelCatalogSlugs } from "../src/codex/custom-model-catalog-migration"; let root = ""; let codexHome = ""; @@ -406,6 +407,66 @@ test("convergence preserves only provider-local degraded rows", async () => { expect(models.some(entry => entry.slug === "external/vendor-model")).toBe(true); }); +function legacyCustomDeletionConfig(): OcxConfig { + const nextConfig = config(false); + nextConfig.providers.offline = { + adapter: "openai-chat", + baseUrl: "https://offline.example.test/v1", + authMode: "oauth", + models: ["fallback"], + }; + nextConfig.customModels = [{ + id: "legacy-custom-id", + provider: "offline", + modelId: "my-model", + addedAt: "2026-08-01T00:00:00.000Z", + }]; + return nextConfig; +} + +test("convergence removes a deleted pre-marker custom row while discovery is degraded", async () => { + const withCustom = legacyCustomDeletionConfig(); + writeFileSync(getConfigPath(), `${JSON.stringify(withCustom, null, 2)}\n`); + writeCatalog([ + nativeEntry(), + generatedRoutedEntry("offline/my-model"), + generatedRoutedEntry("offline/discovered-sibling"), + ]); + + const afterDeletion = structuredClone(withCustom); + delete afterDeletion.customModels; + const models = (await convergeCatalog(afterDeletion)).models ?? []; + + expect(legacyCustomModelCatalogSlugs(afterDeletion)).toEqual( + new Set(["offline/my-model"]), + ); + expect(models.some(entry => entry.slug === "offline/my-model")).toBe(false); + expect(models.some(entry => entry.slug === "offline/discovered-sibling")).toBe(true); +}); + +test("retained sync removes a deleted pre-marker custom row while discovery is degraded", async () => { + const withCustom = legacyCustomDeletionConfig(); + writeFileSync(getConfigPath(), `${JSON.stringify(withCustom, null, 2)}\n`); + writeCatalog([ + nativeEntry(), + generatedRoutedEntry("offline/my-model"), + generatedRoutedEntry("offline/discovered-sibling"), + ]); + + const afterDeletion = structuredClone(withCustom); + delete afterDeletion.customModels; + saveConfig(afterDeletion); + const result = await syncCatalogModels(afterDeletion); + const models = (JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog).models ?? []; + + expect(result.catalogWritten).toBe(true); + expect(legacyCustomModelCatalogSlugs(afterDeletion)).toEqual( + new Set(["offline/my-model"]), + ); + expect(models.some(entry => entry.slug === "offline/my-model")).toBe(false); + expect(models.some(entry => entry.slug === "offline/discovered-sibling")).toBe(true); +}); + test("degraded preservation still honors explicit routed visibility policy", async () => { writeCatalog([ nativeEntry(), diff --git a/tests/codex-v2-gate.test.ts b/tests/codex-v2-gate.test.ts index edaf2594d..2b377afb8 100644 --- a/tests/codex-v2-gate.test.ts +++ b/tests/codex-v2-gate.test.ts @@ -1194,6 +1194,7 @@ describe("3-state multi-agent mode", () => { selectedModelsByProvider: new Map(), gatheredProviderNames: new Set(), degradedProviderNames: new Set(), + legacyCustomModelSlugs: new Set(), multiAgentMode: "default", multiAgentV2Enabled: true, exactComboSlugs: new Set(), diff --git a/tests/config-user-edits.test.ts b/tests/config-user-edits.test.ts index 244d13884..32ef00070 100644 --- a/tests/config-user-edits.test.ts +++ b/tests/config-user-edits.test.ts @@ -7,12 +7,14 @@ import { getConfigPath, getDefaultConfig, loadConfig, + mutatePersistedConfig, readConfigDiagnostics, reconcileLiveConfigFromDisk, saveConfig, saveConfigPreservingClaudeCode, validateConfigCandidate, } from "../src/config"; +import { legacyCustomModelCatalogSlugs } from "../src/codex/custom-model-catalog-migration"; import { rateLimitRetryPolicyFor } from "../src/providers/key-failover"; import type { OcxConfig } from "../src/types"; @@ -36,6 +38,17 @@ function diskConfig(): Record { return JSON.parse(readFileSync(getConfigPath(), "utf8")) as Record; } +/** Seed the exact pre-version shape: custom models exist, but no migration cutover does. */ +function writePreVersionCustomConfig(patch: Record = {}): void { + const current = diskConfig(); + delete current.customModelCatalogMigration; + writeFileSync(getConfigPath(), JSON.stringify({ + ...current, + customModels: [customModel("legacy-model")], + ...patch, + }, null, 2) + "\n"); +} + beforeEach(() => { previousHome = process.env.OPENCODEX_HOME; home = mkdtempSync(join(tmpdir(), "ocx-user-edits-")); @@ -54,6 +67,108 @@ afterEach(() => { rmSync(home, { recursive: true, force: true }); }); +function customModel(modelId: string): NonNullable[number] { + return { + id: `custom-${modelId}`, + provider: "test", + modelId, + addedAt: "2026-08-08T00:00:00.000Z", + }; +} + +test("whole-config saves durably capture a pre-version custom-model slug", () => { + writePreVersionCustomConfig(); + const withoutCustom = loadConfig(); + delete withoutCustom.customModels; + saveConfig(withoutCustom); + + expect(legacyCustomModelCatalogSlugs(withoutCustom)).toEqual( + new Set(["test/legacy-model"]), + ); + expect(diskConfig().customModelCatalogMigration).toEqual({ + version: 1, + legacyOwnedSlugs: ["test/legacy-model"], + }); +}); + +test("guarded binding saves project legacy ownership back onto the live config", () => { + writePreVersionCustomConfig(); + const live = loadConfig(); + armClaudeCodeBaseline(live); + reconcileLiveConfigFromDisk(live, structuredClone(live)); + delete live.customModels; + saveConfigPreservingClaudeCode(live); + + expect(legacyCustomModelCatalogSlugs(live)).toEqual(new Set(["test/legacy-model"])); + expect(diskConfig().customModelCatalogMigration).toEqual({ + version: 1, + legacyOwnedSlugs: ["test/legacy-model"], + }); +}); + +test("field-scoped persisted mutations use the final disk snapshot for legacy ownership", () => { + writePreVersionCustomConfig(); + const outcome = mutatePersistedConfig(config => { + delete config.customModels; + return { changed: true, value: "removed" }; + }); + + expect(outcome).toEqual({ status: "committed", value: "removed" }); + expect(legacyCustomModelCatalogSlugs(loadConfig())).toEqual( + new Set(["test/legacy-model"]), + ); +}); + +test("post-version custom models never expand legacy ownership", () => { + const live = loadConfig(); + live.customModels = [customModel("new-model")]; + saveConfig(live); + expect(legacyCustomModelCatalogSlugs(live)).toEqual(new Set()); + + delete live.customModels; + saveConfig(live); + expect(legacyCustomModelCatalogSlugs(live)).toEqual(new Set()); + expect(diskConfig().customModelCatalogMigration).toEqual({ + version: 1, + legacyOwnedSlugs: [], + }); +}); + +test("unrelated recoverable config damage does not hide pre-version ownership", () => { + writePreVersionCustomConfig({ + providers: { + test: { + adapter: "openai-chat", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "k", + allowPrivateNetwork: true, + retryOn429: { attempts: "bad" }, + }, + }, + }); + const live = loadConfig(); + delete live.customModels; + saveConfig(live); + + expect(legacyCustomModelCatalogSlugs(live)).toEqual(new Set(["test/legacy-model"])); +}); + +test("a future migration state survives an older save and grants no deletion authority", () => { + const futureState = { version: 2, opaque: { keep: true } }; + writeDiskConfig({ + customModels: [customModel("legacy-model")], + customModelCatalogMigration: futureState, + }); + const live = loadConfig(); + delete live.customModels; + + saveConfig(live); + + expect(diskConfig().customModelCatalogMigration).toEqual(futureState); + expect(loadConfig().providers.test).toBeDefined(); + expect(legacyCustomModelCatalogSlugs(live)).toEqual(new Set()); +}); + test("a hand edit made while the service holds memory survives a guarded save", () => { const live = loadConfig(); armClaudeCodeBaseline(live); diff --git a/tests/custom-model-catalog-migration.test.ts b/tests/custom-model-catalog-migration.test.ts new file mode 100644 index 000000000..4a8134b0a --- /dev/null +++ b/tests/custom-model-catalog-migration.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; + +import { + projectCustomModelCatalogMigration, + legacyCustomModelCatalogSlugs, +} from "../src/codex/custom-model-catalog-migration"; +import type { OcxConfig, OcxCustomModel } from "../src/types"; + +function config(customModels?: OcxCustomModel[]): OcxConfig { + return { + port: 10100, + providers: { + routed: { + adapter: "openai-chat", + baseUrl: "https://example.test/v1", + }, + }, + defaultProvider: "routed", + ...(customModels ? { customModels } : {}), + }; +} + +function custom(modelId: string): OcxCustomModel { + return { + id: `id-${modelId}`, + provider: "routed", + modelId, + }; +} + +describe("custom-model catalog ownership migration", () => { + test("leaves configs without custom models untouched", () => { + const projected = projectCustomModelCatalogMigration(config(), config()); + expect(projected.customModelCatalogMigration).toBeUndefined(); + }); + + test("captures only pre-version models and versions a fresh add with an empty ledger", () => { + const added = projectCustomModelCatalogMigration(config(), config([custom("new-model")])); + expect(legacyCustomModelCatalogSlugs(added)).toEqual(new Set()); + expect(added.customModelCatalogMigration).toEqual({ + version: 1, + legacyOwnedSlugs: [], + }); + + const removed = projectCustomModelCatalogMigration( + config([custom("old-model")]), + config(), + ); + expect(legacyCustomModelCatalogSlugs(removed)).toEqual(new Set(["routed/old-model"])); + + const renamed = projectCustomModelCatalogMigration( + config([custom("old-model")]), + config([custom("new-model")]), + ); + expect(legacyCustomModelCatalogSlugs(renamed)).toEqual(new Set(["routed/old-model"])); + }); + + test("canonicalizes a legacy raw routed id", () => { + const projected = projectCustomModelCatalogMigration( + config([custom("vendor/model")]), + config(), + ); + expect(legacyCustomModelCatalogSlugs(projected)).toEqual( + new Set(["routed/vendor-model"]), + ); + }); + + test("post-version add and delete never expands legacy ownership", () => { + const versioned = { + ...config(), + customModelCatalogMigration: { version: 1, legacyOwnedSlugs: [] }, + }; + const afterAdd = projectCustomModelCatalogMigration( + versioned, + { ...config([custom("new-model")]), customModelCatalogMigration: versioned.customModelCatalogMigration }, + ); + const afterDelete = projectCustomModelCatalogMigration(afterAdd, { + ...config(), + customModelCatalogMigration: afterAdd.customModelCatalogMigration, + }); + expect(legacyCustomModelCatalogSlugs(afterDelete)).toEqual(new Set()); + }); + + test("a repaired malformed custom-model surface still establishes an empty cutover", () => { + const malformed = { ...config(), customModels: "bad" }; + const afterRepair = projectCustomModelCatalogMigration( + malformed, + config([custom("new-model")]), + ); + expect(afterRepair.customModelCatalogMigration).toEqual({ + version: 1, + legacyOwnedSlugs: [], + }); + + const afterDelete = projectCustomModelCatalogMigration(afterRepair, { + ...config(), + customModelCatalogMigration: afterRepair.customModelCatalogMigration, + }); + expect(legacyCustomModelCatalogSlugs(afterDelete)).toEqual(new Set()); + }); + + test("a stale candidate cannot discard existing legacy ownership evidence", () => { + const persisted = { + ...config(), + customModelCatalogMigration: { + version: 1, + legacyOwnedSlugs: ["routed/old-model"], + }, + }; + const projected = projectCustomModelCatalogMigration(persisted, config()); + expect(legacyCustomModelCatalogSlugs(projected)).toEqual(new Set(["routed/old-model"])); + }); + + test("unknown or malformed state is preserved and grants no deletion authority", () => { + const future = { + ...config([custom("old-model")]), + customModelCatalogMigration: { + version: 2, + opaque: { keep: true }, + }, + }; + const projected = projectCustomModelCatalogMigration(future, config()); + expect(projected.customModelCatalogMigration).toEqual(future.customModelCatalogMigration); + expect(legacyCustomModelCatalogSlugs(projected)).toEqual(new Set()); + + const malformed = { + ...config(), + customModelCatalogMigration: { + version: 1, + legacyOwnedSlugs: ["routed/good", 42], + }, + }; + expect(legacyCustomModelCatalogSlugs(malformed)).toEqual(new Set()); + }); +});