From 15545b3d1cac7cdd66c5dbe89d51589b2df4a094 Mon Sep 17 00:00:00 2001 From: OpenCodex Maintainer Date: Sat, 8 Aug 2026 11:54:27 +0000 Subject: [PATCH] fix(codex): preserve routed models in desktop picker Squash-rebased PR #1244 onto current dev after conflict reconciliation and verification. --- .../content/docs/guides/codex-app-models.md | 28 ++- docs-site/src/content/docs/guides/combos.md | 62 ++++- .../docs/ja/guides/codex-app-models.md | 5 +- .../src/content/docs/ja/guides/combos.md | 12 +- .../ja/reference/configuration/routing.md | 4 +- .../docs/ko/guides/codex-app-models.md | 7 +- .../src/content/docs/ko/guides/combos.md | 10 +- .../ko/reference/configuration/routing.md | 4 +- .../src/content/docs/reference/cli/agents.md | 7 + .../docs/reference/configuration/routing.md | 4 +- .../docs/ru/guides/codex-app-models.md | 5 +- .../src/content/docs/ru/guides/combos.md | 14 +- .../ru/reference/configuration/routing.md | 7 +- .../docs/zh-cn/guides/codex-app-models.md | 19 +- .../src/content/docs/zh-cn/guides/combos.md | 37 ++- .../docs/zh-cn/reference/cli/agents.md | 6 + .../zh-cn/reference/configuration/routing.md | 4 +- gui/src/combo-workspace-data.ts | 47 +++- .../combo-workspace-detail-panel.tsx | 41 +++- gui/src/i18n/de.ts | 7 + gui/src/i18n/en.ts | 7 + gui/src/i18n/ja.ts | 7 + gui/src/i18n/ko.ts | 7 + gui/src/i18n/ru.ts | 7 + gui/src/i18n/zh.ts | 7 + gui/tests/combo-native-alias-editor.test.tsx | 169 +++++++++++++ gui/tests/combo-workspace-dirty.test.tsx | 4 + src/cli/combo.ts | 5 + src/codex/catalog.ts | 2 +- src/codex/catalog/aggregation.ts | 21 +- src/codex/catalog/effort.ts | 7 +- src/codex/catalog/kinds.ts | 2 + src/codex/catalog/metadata.ts | 86 +++++-- src/codex/catalog/native-models.ts | 7 + src/codex/catalog/parsing.ts | 21 +- src/codex/catalog/provider-fetch.ts | 76 +++++- src/codex/catalog/sync.ts | 163 +++++++++++-- src/codex/convergence.ts | 50 ++-- src/combos/index.ts | 3 + src/combos/types.ts | 84 ++++++- src/server/index.ts | 10 +- src/server/management/combo-routes.ts | 62 +++-- src/server/management/model-routes.ts | 17 +- src/server/management/model-rows.ts | 3 +- src/types.ts | 7 + structure/03_catalog-and-subagents.md | 10 +- tests/cli-headless-parity.test.ts | 22 ++ tests/codex-catalog-sync-hardening.test.ts | 73 ++++++ tests/codex-catalog.test.ts | 225 +++++++++++++++++- tests/codex-convergence-contract.test.ts | 80 ++++++- tests/combo-management-api.test.ts | 157 +++++++++++- tests/combo-workspace-data.test.ts | 85 +++++++ tests/combos.test.ts | 83 +++++++ tests/model-visibility-management-api.test.ts | 43 ++++ ...ative-alias-maintainer-regressions.test.ts | 149 ++++++++++++ tests/native-model-toggle.test.ts | 23 ++ tests/native-profile-drain-server.test.ts | 24 +- 57 files changed, 1974 insertions(+), 164 deletions(-) create mode 100644 gui/tests/combo-native-alias-editor.test.tsx create mode 100644 src/codex/catalog/kinds.ts create mode 100644 src/codex/catalog/native-models.ts create mode 100644 tests/native-alias-maintainer-regressions.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 3674e790a0..95116237ff 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -4,8 +4,8 @@ description: How opencodex models appear in Codex App, Codex CLI, and Codex TUI --- opencodex does not patch Codex App. It writes the same Codex configuration and model catalog that -Codex CLI/TUI already use. Because Codex App reads that shared state, routed models can appear in the -App's model picker as normal Codex catalog entries. +Codex CLI/TUI use. The app-server reads that shared state, but some Codex Desktop releases apply a +second remote model allowlist in the renderer and can still remove routed rows from the picker. OpenAI entries use two credential routes: native Codex login and the namespaced `openai-apikey/` API-key transport. Changing `codexAccountMode` between Pool and Direct by @@ -49,6 +49,21 @@ cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json Earlier v1 three-provider configurations migrate automatically into the single option-aware row. +## Desktop remote-allowlist limitation + +If `codex debug models` and app-server `model/list` contain a routed model but Desktop does not show +it, check the upstream [Codex issue #19694](https://github.com/openai/codex/issues/19694). With the +remote `use_hidden_models` policy active, Desktop can keep only ids in its native +`available_models` list and can also display native rows whose catalog visibility is `hide`. +Catalog refreshes and proxy restarts alone cannot change that renderer policy. + +For an equivalent routed model, opencodex provides an explicit, default-off native-alias combo mode. +It publishes an allowlisted bare slug with an honest custom display label and routes that exact slug +through the configured combo before canonical OpenAI routing. It also omits disabled bare native +rows from the effective catalog while compatibility aliases exist, so Desktop cannot resurrect +them by ignoring `visibility`. See [Codex Desktop native-allowlist compatibility](/guides/combos/#codex-desktop-native-allowlist-compatibility) +for the command, disable-key semantics, and safety constraints. + ## Integration path `ocx init`, `ocx start`, and `ocx sync` wire the shared Codex config and catalog into the proxy; see @@ -107,8 +122,13 @@ the configuration manually: - Native GPT ids are bare slugs. Disabling one keeps its catalog entry but changes `visibility` to `hide`, preserving the exact entry for a later re-enable; it hides the bare row and every selector-qualified clone for that model from discovery. -- Native rows come from the supported static set, so a disabled native model stays visible in the - dashboard and can be turned back on. +- With at least one native-alias combo configured, disabled bare native rows are omitted rather than + retained hidden because affected Desktop releases ignore the hidden flag. A bare native slug + shadowed by a native alias is also omitted from the Models page, so it has no native switch there; + only unshadowed native rows remain switchable. Sync restores pristine native metadata when an + unshadowed disabled row is re-enabled. +- Unshadowed native rows come from the supported static set, so a disabled unshadowed model stays + visible in the dashboard and can be turned back on. The visibility pass runs after snapshot upgrades, and the management API refreshes the catalog and forces Codex's model cache stale after a toggle. diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index ff12655b1c..434cdcbb46 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -63,8 +63,8 @@ An optional alias gives the combo a different public model name. An alias: - may be bare, such as `daily-fast`, or contain one `/`, such as `team/daily-fast`; - cannot be `combo` or start with `combo/`; - cannot duplicate another combo alias; and -- cannot be a bare native OpenAI-family name beginning with `gpt-`, `o1-`, `o3-`, `o4-`, or - `codex-`. +- cannot normally be a bare native OpenAI-family name beginning with `gpt-`, `o1-`, `o3-`, `o4-`, + or `codex-`. The explicit Desktop compatibility mode below is the only exception. Even when an alias is set, the canonical `combo/` form still resolves. Canonical lookup runs before alias matching, so an alias cannot take over another combo's canonical id. @@ -74,6 +74,53 @@ Aliases change the public name clients request; they do not change the combo's s concrete provider/model selectors behind it. ::: +## Codex Desktop native-allowlist compatibility + +Some Codex Desktop releases apply a remote native-only `available_models` allowlist after the +app-server has already loaded `model_catalog_json`. Normal routed ids such as +`Nova1/codex-gpt-5.6-sol` are then usable by the CLI but absent from the Desktop picker. This is the +upstream [Codex Desktop bug](https://github.com/openai/codex/issues/19694) tracked by +[opencodex #241](https://github.com/lidge-jun/opencodex/issues/241). + +When you control an equivalent routed target, a combo can explicitly take over one native slug: + +```bash +ocx combo set nova-sol \ + --targets Nova1/codex/gpt-5.6-sol \ + --alias gpt-5.6-sol \ + --native-alias \ + --display-name 'Nova1 - codex-gpt-5.6-sol' +``` + +This mode is deliberately opt-in and requires both `--native-alias` and a non-empty display label. +The alias must be one of the native model ids supported by this opencodex release; a native-family +prefix alone is not accepted because removal must be able to restore authoritative metadata. +When the routed target's discovery response supplies only a model id, the compatibility row fills +missing context, modality, and reasoning metadata from the native id it replaces. Explicit target +limits still win, so this fallback never raises a context cap or overrides declared capabilities. +It changes exact routing precedence: requests for `gpt-5.6-sol` resolve to `combo/nova-sol` before +the canonical OpenAI native-family route. The catalog contains one bare row with the configured +display label, not duplicate native and combo rows. Only the bare `gpt-5.6-sol` slug is captured. +Account-qualified rows such as `main/gpt-5.6-sol` and provider-qualified rows such as +`openai-apikey/gpt-5.6-sol` remain distinct OpenAI routes; the provider-qualified API-key route +never falls through to the native alias. + +Visibility keys stay unambiguous: + +- `combo/nova-sol` hides the compatibility combo from discovery. +- The bare `gpt-5.6-sol` entry in `disabledModels` continues to mean the dormant native OpenAI row; + it does not hide the combo that currently owns that public slug. +- While at least one native alias is configured, disabled bare native rows are omitted from the + effective Codex catalog instead of retained as `visibility: "hide"`. This prevents Desktop's + allowlist from resurrecting rows it should not show. The Models page still lists unshadowed native + switches, and re-enabling one restores its preserved or current native metadata. + +:::caution +A native alias intentionally takes over a first-party-looking model id. Use it only when the target +is operationally equivalent and label the picker row honestly. Removing the combo restores normal +native routing and catalog identity on the next sync. +::: + ## Choose a strategy ### Failover: ordered primary and backups @@ -214,10 +261,11 @@ ocx combo set --targets provider/model[:weight],... ocx combo remove --yes ``` -`set` also accepts `--strategy`, `--sticky`, `--effort`, `--alias`, and `--rename-from`. Use `-` -as the value of `--effort` or `--alias` to clear that field. `create` and `update` are aliases for -`set`; `delete` is an alias for `remove`; and the same subcommands are available under -`ocx route combo`. +`set` also accepts `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, +`--display-name`, and `--rename-from`. Use `-` as the value of `--effort`, `--alias`, or +`--display-name` to clear that field. `--native-alias` requires a currently supported bare native +model alias and a non-empty display name. `create` and `update` are aliases for `set`; `delete` is an alias for +`remove`; and the same subcommands are available under `ocx route combo`. ### Management API @@ -257,6 +305,8 @@ Combos are stored in the top-level `combos` object, keyed by combo id: | `stickyLimit` | No | `1` | Integer from 1 to 100 successful requests per round-robin selection. | | `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max`, or `ultra`; applied only when the caller omits effort and the target advertises support. | | `alias` | No | none | Optional trimmed public model id; use the alias rules above. An empty value is stored as no alias. | +| `nativeAlias` | No | `false` | Explicitly permit a currently supported bare native `alias` to take routing and catalog precedence. Never inferred from the alias. | +| `displayName` | No | none | Bounded display-only catalog label. Required and non-empty when `nativeAlias` is true. | ## Troubleshooting 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 7e5e3df873..8a992e0274 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 @@ -3,7 +3,10 @@ title: Codex App モデル ピッカー description: opencodex モデルが、共有 Codex カタログを通じて Codex App、Codex CLI、Codex TUI にどのように表示されるか。 --- -opencodex は Codex アプリにパッチを適用しません。 Codex CLI/TUI が既に使用しているのと同じ Codex 設定とモデル カタログを書き込みます。 Codex アプリはその共有状態を読み取るため、ルーティングされたモデルは通常の Codex カタログ エントリとしてアプリのモデル ピッカーに表示されます。 +opencodex は Codex アプリにパッチを適用しません。Codex CLI/TUI と同じ Codex 設定とモデル +カタログを書き込みます。app-server はその共有状態を読み取りますが、一部の Codex Desktop +リリースは renderer 側で追加の remote allowlist を適用し、routed row を picker から除外する +ことがあります。明示的な `nativeAlias: true` combo が、この上流不具合向けの互換モードです。 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) を参照してください。 diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 64d34ef652..7cad2b0b6e 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -53,8 +53,8 @@ ocx combo show main - `daily-fast` のように裸の場合もあれば、`team/daily-fast` のように `/` を 1 つ含む場合もあります。 - `combo` にしたり、`combo/` で始めることはできません。 - 別のコンボ エイリアスを複製することはできません。そして -- `gpt-`、`o1-`、`o3-`、`o4-`、またはで始まる裸のネイティブ OpenAI ファミリ名は使用できません。 -`codex-`。 +- 通常、`gpt-`、`o1-`、`o3-`、`o4-`、または `codex-` で始まる bare native OpenAI + family 名は使用できません。明示的な `nativeAlias: true` Desktop 互換モードだけが例外です。 エイリアスが設定されている場合でも、正規の `combo/` 形式は解決されます。正規ルックアップはエイリアスの一致の前に実行されるため、エイリアスが別のコンボの正規 ID を引き継ぐことはできません。 @@ -175,7 +175,11 @@ ocx combo set --targets provider/model[:weight],... ocx combo remove --yes ``` -`set` は、`--strategy`、`--sticky`、`--effort`、`--alias`、および `--rename-from` も受け入れます。そのフィールドをクリアするには、`--effort` または `--alias` の値として `-` を使用します。 `create` および `update` は、`set` のエイリアスです。 `delete` は `remove` のエイリアスです。同じサブコマンドが `ocx route combo` で使用できます。 +`set` は、`--strategy`、`--sticky`、`--effort`、`--alias`、`--native-alias`、 +`--display-name`、および `--rename-from` も受け入れます。`--native-alias` には現在 +サポートされている bare native alias と空でない display name が必要です。`--effort`、`--alias`、 +`--display-name` に `-` を指定すると、その値をクリアできます。`create` および `update` は `set` の +エイリアス、`delete` は `remove` のエイリアスです。すべてのコンボ サブコマンドは `ocx route combo ...` からも利用できます。 ### 管理 API @@ -212,6 +216,8 @@ ocx combo remove --yes | `stickyLimit` |いいえ | `1` |ラウンドロビン選択ごとの成功したリクエストの数は 1 ~ 100 の整数です。 | | `defaultEffort` |いいえ | `null` | `low`、`medium`、`high`、`xhigh`、`max`、または `ultra`;呼び出し元が努力を省略し、ターゲットがサポートをアドバタイズした場合にのみ適用されます。 | | `alias` |いいえ |なし |オプションのトリミングされたパブリック モデル ID。上記のエイリアス ルールを使用します。空の値はエイリアスなしで保存されます。 | +| `nativeAlias` |いいえ | `false` | 現在サポートされている bare native alias に routing/catalog の優先権を明示的に与えます。 | +| `displayName` |いいえ |なし | catalog 表示専用ラベル。`nativeAlias` が true の場合は必須です。 | ## トラブルシューティング diff --git a/docs-site/src/content/docs/ja/reference/configuration/routing.md b/docs-site/src/content/docs/ja/reference/configuration/routing.md index 2eda8fa3c7..7b6b9349b7 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ja/reference/configuration/routing.md @@ -46,7 +46,7 @@ selector の後には bare native OpenAI-family id だけを指定できます ## コンボ (`config.combos`) -各コンボ キーは `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` に一致する ID です。これは常に `combo/` として直接アドレス指定可能であり、1 つの `alias` を公開することもあります。エイリアスは一意である必要があり、`combo/` 名前空間を占有することはできず、`gpt-*`、`o1-*`、`o3-*`、`o4-*`、または `codex-*` などの予約されたベア ネイティブ ファミリを使用することはできません。 +各コンボ キーは `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` に一致する ID です。これは常に `combo/` として直接アドレス指定可能であり、1 つの `alias` を公開することもあります。エイリアスは一意である必要があり、`combo/` 名前空間を占有することはできず、通常は `gpt-*`、`o1-*`、`o3-*`、`o4-*`、または `codex-*` などの予約された bare native family を使用できません。明示的な `nativeAlias: true` Desktop 互換契約だけが例外です。 |キー |タイプ |デフォルト |意味 | | --- | --- | --- | --- | @@ -55,6 +55,8 @@ selector の後には bare native OpenAI-family id だけを指定できます | `stickyLimit?` | `number` | `1` |成功したリクエストは 1 つのラウンドロビン バッチに保持されます。範囲は 1 ~ 100。 | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` |設定を解除する |呼び出し元が努力を省略し、選択されたターゲットが要求されたラングをアドバタイズする場合にのみ適用されます。 | | `alias?` | `string` | — |正規のピッカー スラグの代わりのオプションのパブリック モデル ID。 | +| `nativeAlias?` | `boolean` | `false` | 現在サポートされている bare native id に限り、その未修飾 id で優先します。アカウント修飾およびプロバイダー修飾の OpenAI ルートは別のままです。 | +| `displayName?` | `string` | — | catalog 表示専用ラベル。native alias では空でない値が必須です。 | ```json { 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 4083aa3a95..b6f31f1b5f 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 @@ -3,9 +3,10 @@ title: Codex App 모델 선택기 description: 공유 Codex 카탈로그를 통해 opencodex 모델이 Codex App, Codex CLI, Codex TUI에 표시되는 방식. --- -opencodex는 Codex App을 직접 고치지 않습니다. Codex CLI/TUI가 이미 쓰는 Codex 설정과 모델 카탈로그를 -같은 위치에 씁니다. Codex App도 이 공유 상태를 읽기 때문에, 라우팅된 모델이 일반 Codex 카탈로그 -항목처럼 App의 모델 선택기에 나타날 수 있습니다. +opencodex는 Codex App을 직접 고치지 않습니다. Codex CLI/TUI와 같은 Codex 설정과 모델 카탈로그를 +씁니다. app-server는 이 공유 상태를 읽지만, 일부 Codex Desktop 릴리스는 renderer에서 추가 remote +allowlist를 적용해 routed row를 picker에서 제거할 수 있습니다. 명시적 `nativeAlias: true` combo가 +이 업스트림 버그를 위한 호환 모드입니다. OpenAI 항목에는 네이티브 Codex 로그인과 네임스페이스가 붙은 `openai-apikey/` API key 경로라는 두 가지 credential 경로가 있습니다. `codexAccountMode`만 Pool과 Direct 사이에서 바꾸는 것은 diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index e663cd5fb3..cec9ed6258 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -53,7 +53,8 @@ ocx combo show main - `daily-fast`처럼 단독일 수도 있고, `team/daily-fast`처럼 `/`를 하나 포함할 수도 있습니다. - `combo`일 수 없고 `combo/`로 시작할 수도 없습니다. - 다른 콤보 alias와 중복될 수 없습니다. -- `gpt-`, `o1-`, `o3-`, `o4-`, `codex-`로 시작하는 bare OpenAI 계열 이름일 수 없습니다. +- 일반적으로 `gpt-`, `o1-`, `o3-`, `o4-`, `codex-`로 시작하는 bare OpenAI 계열 이름일 수 + 없습니다. 명시적 `nativeAlias: true` Desktop 호환 모드만 예외입니다. alias를 설정해도 정식 `combo/` 형식은 계속 해석됩니다. 정식 조회가 alias 매칭보다 먼저 실행되므로, alias가 다른 콤보의 정식 ID를 가로챌 수는 없습니다. @@ -174,7 +175,10 @@ ocx combo set --targets provider/model[:weight],... ocx combo remove --yes ``` -`set`은 `--strategy`, `--sticky`, `--effort`, `--alias`, `--rename-from`도 받습니다. `--effort` 또는 `--alias` 값으로 `-`를 주면 해당 필드를 지울 수 있습니다. `create`와 `update`는 `set`의 별칭이고, `delete`는 `remove`의 별칭입니다. 같은 하위 명령은 `ocx route combo` 아래에서도 사용할 수 있습니다. +`set`은 `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, `--display-name`, +`--rename-from`도 받습니다. `--native-alias`에는 현재 지원되는 bare native alias와 비어 있지 않은 display name이 필요합니다. `--effort`, `--alias`, +`--display-name`에 `-`를 지정하면 해당 값을 지울 수 있습니다. `create`와 `update`는 `set`의 +별칭이고 `delete`는 `remove`의 별칭입니다. 모든 combo 하위 명령은 `ocx route combo ...`에서도 사용할 수 있습니다. ### Management API @@ -211,6 +215,8 @@ ocx combo remove --yes | `stickyLimit` | 아니요 | `1` | round-robin 선택 한 번당 성공 요청 1에서 100회 사이의 정수입니다. | | `defaultEffort` | 아니요 | `null` | `low`, `medium`, `high`, `xhigh`, `max`, 또는 `ultra`입니다. 호출자가 effort를 생략하고 대상이 지원을 광고할 때만 적용됩니다. | | `alias` | 아니요 | 없음 | 선택적으로 앞뒤 공백을 제거한 공개 모델 ID입니다. 위의 alias 규칙을 따릅니다. 빈 값은 alias 없음으로 저장됩니다. | +| `nativeAlias` | 아니요 | `false` | 현재 지원되는 bare native alias가 routing/catalog 우선권을 갖도록 명시적으로 허용합니다. | +| `displayName` | 아니요 | 없음 | catalog 표시 전용 label입니다. `nativeAlias`가 true이면 필수입니다. | ## 문제 해결 diff --git a/docs-site/src/content/docs/ko/reference/configuration/routing.md b/docs-site/src/content/docs/ko/reference/configuration/routing.md index 4615b63620..d325f9cc84 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ko/reference/configuration/routing.md @@ -45,7 +45,7 @@ selector는 표시되지 않습니다. selector 검증, 충돌 규칙, privacy g ## Combos (`config.combos`) -각 combo 키는 `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`에 맞는 id입니다. 항상 `combo/`로 직접 접근할 수 있고, 추가로 하나의 `alias`를 노출할 수 있습니다. alias는 유일해야 하고 `combo/` 네임스페이스를 차지할 수 없으며, `gpt-*`, `o1-*`, `o3-*`, `o4-*`, `codex-*` 같은 예약된 bare native family도 사용할 수 없습니다. +각 combo 키는 `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`에 맞는 id입니다. 항상 `combo/`로 직접 접근할 수 있고, 추가로 하나의 `alias`를 노출할 수 있습니다. alias는 유일해야 하고 `combo/` 네임스페이스를 차지할 수 없으며, 일반적으로 `gpt-*`, `o1-*`, `o3-*`, `o4-*`, `codex-*` 같은 예약된 bare native family를 사용할 수 없습니다. 명시적 `nativeAlias: true` Desktop 호환 계약만 예외입니다. | Key | Type | Default | Meaning | | --- | --- | --- | --- | @@ -54,6 +54,8 @@ selector는 표시되지 않습니다. selector 검증, 충돌 규칙, privacy g | `stickyLimit?` | `number` | `1` | 한 round-robin 배치에서 유지되는 성공 요청 수입니다. 범위는 1–100입니다. | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | 호출자가 effort를 생략했고 선택된 대상이 요청한 rung를 광고할 때만 적용됩니다. | | `alias?` | `string` | — | 정규화된 picker slug 대신 쓰는 선택적 공개 model id입니다. | +| `nativeAlias?` | `boolean` | `false` | 현재 지원되는 bare native id가 해당 비수식 id에만 우선하도록 합니다. 계정 또는 프로바이더로 수식된 OpenAI route는 별도로 유지됩니다. | +| `displayName?` | `string` | — | catalog 표시 전용 label이며 native alias에서는 비어 있지 않아야 합니다. | ```json { diff --git a/docs-site/src/content/docs/reference/cli/agents.md b/docs-site/src/content/docs/reference/cli/agents.md index 4944a483d2..4de09b5284 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -57,6 +57,13 @@ ocx combo list ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ``` +`set` accepts `--strategy`, `--sticky`, `--effort`, `--alias`, `--rename-from`, `--native-alias`, and +`--display-name ` (`-` clears the label). A native alias captures only one currently supported, +unqualified bare OpenAI model id. Bare `gpt-5.6-*` native aliases use Codex Pool/Direct credentials. +Account-qualified OpenAI routes remain distinct, while provider-qualified routes such as +`openai-apikey/gpt-5.6-*` use their configured API key and never fall through to the native alias. +Read the safety and visibility contract in the guide before enabling the compatibility pair. + See [Combos](/guides/combos/) for routing behavior and configuration guidance. ## Observability and debug diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 862dd9867c..d9286aad65 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -55,7 +55,7 @@ stored account is missing are not advertised. Selector validation, collision rul Each combo key is an id matching `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`. It is always directly addressable as `combo/` and may also expose one `alias`. Aliases must be unique, cannot occupy the `combo/` namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`, `o3-*`, `o4-*`, or -`codex-*`. +`codex-*` unless `nativeAlias: true` explicitly enables the Desktop compatibility contract. | Key | Type | Default | Meaning | | --- | --- | --- | --- | @@ -64,6 +64,8 @@ namespace, and cannot use reserved bare native families such as `gpt-*`, `o1-*`, | `stickyLimit?` | `number` | `1` | Successful requests retained in one round-robin batch. Range 1–100. | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | Applied only when the caller omits effort and the selected target advertises the requested rung. | | `alias?` | `string` | — | Optional public model id in place of the canonical picker slug. | +| `nativeAlias?` | `boolean` | `false` | Let a currently supported bare native id take precedence only for that unqualified id. Bare `gpt-5.6-*` ids use Codex Pool/Direct credentials. Account-qualified routes remain distinct. Provider-qualified routes such as `openai-apikey/gpt-5.6-*` use their configured API-key route and never fall through to the native alias. | +| `displayName?` | `string` | — | Display-only catalog label, required and non-empty for a native alias. | ```json { 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 9d299bf1ad..425e71464c 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 @@ -4,8 +4,9 @@ description: Как модели opencodex появляются в Codex App, Co --- opencodex не патчит Codex App. Он записывает ту же конфигурацию Codex и тот же каталог моделей, -которыми уже пользуются Codex CLI/TUI. Поскольку Codex App читает это общее состояние, -маршрутизируемые модели могут появляться в picker'е App как обычные записи каталога Codex. +которыми пользуются Codex CLI/TUI. App-server читает это общее состояние, но некоторые версии +Codex Desktop применяют в renderer дополнительный remote allowlist и могут удалить routed-строки +из picker'а. Явная combo с `nativeAlias: true` — режим совместимости для этой upstream-ошибки. Записи OpenAI используют два credential-транспорта: нативный вход Codex и namespaced-транспорт API-ключа `openai-apikey/`. Само по себе переключение `codexAccountMode` между Pool и Direct diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index 52ccd16220..7257298678 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -65,8 +65,9 @@ ocx combo show main `team/daily-fast`; - не может быть `combo` и не может начинаться с `combo/`; - не может дублировать alias другой combo; и -- не может быть «голым» нативным именем семейства OpenAI, начинающимся с `gpt-`, `o1-`, `o3-`, - `o4-` или `codex-`. +- обычно не может быть «голым» нативным именем семейства OpenAI, начинающимся с `gpt-`, `o1-`, + `o3-`, `o4-` или `codex-`; единственное исключение — явный режим совместимости Desktop + `nativeAlias: true`. Даже если alias задан, каноническая форма `combo/` всё равно разрешается. Канонический поиск выполняется раньше сопоставления alias, поэтому alias не может перехватить канонический id другой @@ -220,10 +221,9 @@ ocx combo set --targets provider/model[:weight],... ocx combo remove --yes ``` -`set` также принимает `--strategy`, `--sticky`, `--effort`, `--alias` и `--rename-from`. Чтобы -очистить поле, используйте `-` в качестве значения для `--effort` или `--alias`. `create` и -`update` — это alias для `set`; `delete` — alias для `remove`; те же подкоманды доступны и через -`ocx route combo`. +`set` также принимает `--strategy`, `--sticky`, `--effort`, `--alias`, `--native-alias`, +`--display-name` и `--rename-from`. Для `--native-alias` нужны поддерживаемый сейчас bare native +alias и непустой display name. `create` и `update` — alias для `set`, а `delete` — alias для `remove`. ### Management API @@ -263,6 +263,8 @@ Combo хранятся в объекте верхнего уровня `combos`, | `stickyLimit` | No | `1` | Целое число от 1 до 100 успешных запросов на один выбор round-robin. | | `defaultEffort` | No | `null` | `low`, `medium`, `high`, `xhigh`, `max` или `ultra`; применяется только когда вызывающая сторона не указала effort, а цель объявляет поддержку. | | `alias` | No | none | Необязательный обрезанный публичный id модели; используйте правила alias выше. Пустое значение хранится как отсутствие alias. | +| `nativeAlias` | No | `false` | Явно разрешает поддерживаемому сейчас bare native alias перехватить приоритет routing/catalog только для неквалифицированного id. Bare `gpt-5.6-*` использует учётные данные Codex Pool/Direct; маршруты с квалификатором аккаунта сохраняют свою идентичность, а provider-qualified `openai-apikey/gpt-5.6-*` использует API-ключ и никогда не переходит на native alias. | +| `displayName` | No | none | Метка только для отображения в catalog; обязательна при `nativeAlias: true`. | ## Устранение неполадок diff --git a/docs-site/src/content/docs/ru/reference/configuration/routing.md b/docs-site/src/content/docs/ru/reference/configuration/routing.md index 429610c00d..33f55ea7ad 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ru/reference/configuration/routing.md @@ -53,8 +53,9 @@ opencodex разрешает запрошенную модель в следую Каждый ключ combo — это id, соответствующий шаблону `[A-Za-z0-9][A-Za-z0-9._-]{0,63}`. Он всегда доступен напрямую как `combo/` и может также предоставлять один `alias`. Алиасы должны быть -уникальны, не могут занимать пространство имён `combo/` и не могут использовать зарезервированные -голые нативные семейства, например `gpt-*`, `o1-*`, `o3-*`, `o4-*` или `codex-*`. +уникальны, не могут занимать пространство имён `combo/` и обычно не могут использовать +зарезервированные голые нативные семейства, например `gpt-*`, `o1-*`, `o3-*`, `o4-*` или +`codex-*`. Исключение — явно включённый контракт совместимости Desktop `nativeAlias: true`. | Ключ | Тип | По умолчанию | Значение | | --- | --- | --- | --- | @@ -63,6 +64,8 @@ opencodex разрешает запрошенную модель в следую | `stickyLimit?` | `number` | `1` | Число успешных запросов, удерживаемых в одной партии round-robin. Диапазон 1–100. | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | Применяется, только если вызывающая сторона не задала effort, а выбранная цель объявляет эту ступень. | | `alias?` | `string` | — | Необязательный публичный id модели вместо канонического slug в селекторе. | +| `nativeAlias?` | `boolean` | `false` | Даёт поддерживаемому bare native id приоритет только для этого неквалифицированного id. Bare `gpt-5.6-*` использует учётные данные Codex Pool/Direct. Маршруты с квалификатором аккаунта остаются отдельными. Провайдер-квалифицированные маршруты, например `openai-apikey/gpt-5.6-*`, используют настроенный API-ключ и никогда не переходят на native alias. | +| `displayName?` | `string` | — | Метка только для catalog; для native alias обязательна и не может быть пустой. | ```json { 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 da5a2400a0..c5773baae9 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 @@ -3,7 +3,9 @@ title: Codex App 模型选择器 description: opencodex 中的模型如何通过共享 Codex 目录出现在 Codex App、Codex CLI 和 Codex TUI 中。 --- -opencodex 不会修改 Codex App。它会写入 Codex CLI/TUI 已经使用的同一套 Codex 配置和模型目录。因为 Codex App 读取的是这份共享状态,路由模型可以像普通 Codex 目录条目一样出现在 App 的模型选择器中。 +opencodex 不会修改 Codex App。它会写入 Codex CLI/TUI 使用的同一套 Codex 配置和模型目录。 +app-server 会读取这份共享状态,但部分 Codex Desktop 版本还会在 renderer 中应用第二层远程 +allowlist,因此仍可能从选择器里删掉路由模型。 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)。 @@ -28,6 +30,19 @@ cp ~/.opencodex/config.json.pre-openai-tiers-v2.bak ~/.opencodex/config.json 更早的 v1 三 provider 配置会自动迁移到这个支持单一选项的行。 +## Desktop 远程 allowlist 限制 + +如果 `codex debug models` 和 app-server 的 `model/list` 都包含某个路由模型,但 Desktop +没有显示它,根因通常是上游 [Codex #19694](https://github.com/openai/codex/issues/19694)。 +启用远程 `use_hidden_models` 后,Desktop 可能只保留 `available_models` 中的原生 id,甚至会 +重新显示 catalog 中已标记为 `hide` 的原生行。单纯刷新 catalog 或重启代理无法改变 renderer +策略。 + +对于等价的路由模型,opencodex 提供默认关闭的 native-alias combo 兼容模式:用明确的显示标签 +发布 allowlist 接受的裸 slug,并让该 slug 在规范 OpenAI 路由之前进入指定 combo。只要配置了 +native alias,已禁用的裸原生行就会从有效 catalog 中移除,避免 Desktop 无视隐藏状态将其复活。 +命令、禁用键语义和安全限制见 [Codex Desktop 原生 allowlist 兼容模式](/zh-cn/guides/combos/#codex-desktop-原生-allowlist-兼容模式)。 + ## 集成路径 `ocx init`、`ocx start` 和 `ocx sync` 会把共享的 Codex 配置和目录接入代理;有关配置注入、目录同步、shim、WebSocket fallback 和恢复机制,请参见 [Codex Integration](/guides/codex-integration/)。 @@ -71,6 +86,8 @@ visibility = "list" `disabledModels` 只会隐藏对应的 selector 行。 - 裸原生 GPT id 是裸 slug。禁用后会隐藏裸行以及该模型的所有 account-selector 克隆行, 同时保留目录条目以便之后重新启用。 +- 配置 native-alias combo 后,被该 combo 遮蔽的裸原生行会从 Models 页面移除且不再显示原生开关; + 只有未被遮蔽的原生行保留开关,并可在重新启用时恢复原生 metadata。 - 原生行来自受支持的静态集合,因此被禁用的原生模型仍会在仪表盘中可见,并且可以重新打开。 可见性处理会在快照升级之后运行;每次切换后,管理 API 都会刷新目录,并强制让 Codex 的模型缓存失效。 diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index d0342c2a40..244d3d71ba 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -53,7 +53,8 @@ ocx combo show main - 可以是无斜杠形式,例如 `daily-fast`,也可以包含一个 `/`,例如 `team/daily-fast`; - 不能是 `combo` 或以 `combo/` 开头; - 不能与其他 combo 别名重复;并且 -- 不能是以 `gpt-`、`o1-`、`o3-`、`o4-` 或 `codex-` 开头的裸原生 OpenAI 系列名称。 +- 通常不能是以 `gpt-`、`o1-`、`o3-`、`o4-` 或 `codex-` 开头的裸原生 OpenAI 系列名称; + 唯一例外是下方显式启用的 Desktop 兼容模式。 即使设置了别名,规范的 `combo/` 形式仍然可以解析。规范查找会先于别名匹配,因此别名不能抢占另一个 combo 的规范 id。 @@ -61,6 +62,32 @@ ocx combo show main 别名只会改变客户端请求的公开名称,不会改变 combo 存储的 id,也不会改变其背后的具体 provider/model 选择器。 ::: +## Codex Desktop 原生 allowlist 兼容模式 + +部分 Codex Desktop 版本会在 app-server 已经加载 `model_catalog_json` 后,再用远程 +`available_models` allowlist 过滤选择器。这会让普通的 `Nova1/...` 路由模型在 CLI 中可用, +却不出现在 Desktop。可以显式让一个 combo 接管对应的裸原生 slug: + +```bash +ocx combo set nova-sol \ + --targets Nova1/codex/gpt-5.6-sol \ + --alias gpt-5.6-sol \ + --native-alias \ + --display-name 'Nova1 - codex-gpt-5.6-sol' +``` + +该模式默认关闭,同时要求 `--native-alias` 和非空显示名称。alias 必须是当前 opencodex 版本 +明确支持的原生 model id;只有原生系列前缀还不够,因为移除 alias 时必须能恢复权威 metadata。 +如果路由目标的 discovery 只返回 model id,兼容行会从被接管的原生 id 补齐缺失的 context、 +modality 和 reasoning metadata;目标显式声明的限制仍然优先,因此不会抬高 context cap 或覆盖 +已经声明的能力。 +`gpt-5.6-sol` 请求会先解析到 +`combo/nova-sol`,catalog 中只保留一条带明确 Nova 标签的裸行。`combo/nova-sol` 用于禁用 +这个 combo;`disabledModels` 中裸的 `gpt-5.6-sol` 仍只表示原生 OpenAI 行,不会误禁 combo。 +配置任意 native alias 后,其他已禁用的裸原生行也会从有效 catalog 中移除,而不是仅标成 +`visibility: "hide"`,从而防止 Desktop 无视隐藏标记后把它们重新显示出来。账户限定的 +`main/gpt-5.6-sol` 仍是真实 OpenAI 路由。删除 combo 后,下次同步会恢复正常原生身份。 + ## 选择策略 ### 故障切换:按顺序的主目标和备用目标 @@ -174,7 +201,11 @@ ocx combo set --targets provider/model[:weight],... ocx combo remove --yes ``` -`set` 也接受 `--strategy`、`--sticky`、`--effort`、`--alias` 和 `--rename-from`。将 `--effort` 或 `--alias` 的值设为 `-` 可清除该字段。`create` 和 `update` 是 `set` 的别名;`delete` 是 `remove` 的别名;同样的子命令也可通过 `ocx route combo` 使用。 +`set` 也接受 `--strategy`、`--sticky`、`--effort`、`--alias`、`--native-alias`、 +`--display-name` 和 `--rename-from`。将 `--effort`、`--alias` 或 `--display-name` 的值设为 +`-` 可清除该字段。`--native-alias` 必须配合当前受支持的裸原生 alias 和非空显示名称使用。 +`create` 和 `update` 是 `set` 的别名;`delete` 是 `remove` 的别名;同样的子命令也可通过 +`ocx route combo` 使用。 ### Management API @@ -211,6 +242,8 @@ combo 会存储在顶层的 `combos` 对象中,并以 combo id 作为键: | `stickyLimit` | 否 | `1` | 每次轮询选择可连续处理的成功请求数,范围为 1 到 100。 | | `defaultEffort` | 否 | `null` | `low`、`medium`、`high`、`xhigh`、`max` 或 `ultra`;仅当调用方省略 effort 且目标声明支持时才会应用。 | | `alias` | 否 | 无 | 可选的、已修剪的公开模型 id;使用上面的别名规则。空值会以“无别名”形式存储。 | +| `nativeAlias` | 否 | `false` | 显式允许当前受支持的裸原生 alias 接管路由和 catalog 优先级;绝不会根据 alias 自动推断。 | +| `displayName` | 否 | 无 | 仅用于 catalog 展示的有界标签;`nativeAlias` 为 true 时必须非空。 | ## 故障排查 diff --git a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md index dc1960adb3..d4daa82695 100644 --- a/docs-site/src/content/docs/zh-cn/reference/cli/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/cli/agents.md @@ -50,6 +50,12 @@ ocx combo list ocx route combo set reliable --targets ark/model-a:2,openai/gpt-5.5 ``` +`set` 支持 `--strategy`、`--sticky`、`--effort`、`--alias`、`--rename-from`、`--native-alias` +以及 `--display-name `(`-` 会清除标签)。native alias 只会接管一个当前受支持且 +不带限定前缀的 OpenAI 裸 model id。裸 `gpt-5.6-*` native alias 使用 Codex Pool/Direct 凭据; +带账号限定的 OpenAI 路由仍保持独立,而 `openai-apikey/gpt-5.6-*` 这类提供方限定路由使用其配置的 +API key,且绝不会回退到 native alias。启用这组兼容选项前,请先阅读 Combos 指南中的安全和可见性契约。 + 有关路由行为和配置指导,请参见 [Combos](/guides/combos/)。 ## Observability and debug diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md index b718ed4ed7..74192f6500 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md @@ -49,7 +49,7 @@ selector 校验、冲突规则和隐私说明见[提供方配置](/reference/con 每个 combo 键都是一个符合 `[A-Za-z0-9][A-Za-z0-9._-]{0,63}` 的 id。它始终可以直接通过 `combo/` 访问,也可以额外暴露一个 `alias`。别名必须唯一,不能占用 `combo/` 命名空间,也不能使用保留的原生裸系列,例如 `gpt-*`、`o1-*`、`o3-*`、`o4-*` 或 -`codex-*`。 +`codex-*`,除非通过 `nativeAlias: true` 显式启用 Desktop 兼容契约。 | 键 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | @@ -58,6 +58,8 @@ selector 校验、冲突规则和隐私说明见[提供方配置](/reference/con | `stickyLimit?` | `number` | `1` | 在单个轮询批次中保留的成功请求数。范围 1–100。 | | `defaultEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max" \| "ultra" \| null` | unset | 仅在调用方省略 effort 且所选目标声明了请求的档位时应用。 | | `alias?` | `string` | — | 可选的公开 model id,用于替代规范化的选择器 slug。 | +| `nativeAlias?` | `boolean` | `false` | 仅让当前受支持的裸原生 id 对该不带限定前缀的 id 优先;带账号或提供方限定的 OpenAI 路由仍是独立路由。 | +| `displayName?` | `string` | — | 仅用于 catalog 展示的标签;native alias 必须提供非空值。 | ```json { diff --git a/gui/src/combo-workspace-data.ts b/gui/src/combo-workspace-data.ts index aa32facedd..56ce088238 100644 --- a/gui/src/combo-workspace-data.ts +++ b/gui/src/combo-workspace-data.ts @@ -3,6 +3,10 @@ * No network — transforms GET /api/combos rows into rail groups + attention. */ +import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../../src/codex/catalog/native-models"; + +export { SUPPORTED_NATIVE_OPENAI_SLUGS }; + export type ComboStrategy = "failover" | "round-robin"; export type ComboEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; @@ -62,6 +66,10 @@ export interface ComboItem { model: string; /** Optional public model name replacing the default combo/ slug; null = default. */ alias: string | null; + /** Explicit takeover of a bare OpenAI-native alias. */ + nativeAlias: boolean; + /** Display-only catalog label used by native aliases. */ + displayName: string | null; strategy: ComboStrategy; stickyLimit: number; defaultEffort: ComboEffort | null; @@ -98,6 +106,19 @@ export function comboPublicModelId(id: string, alias: string | null | undefined) return trimmed || comboModelId(id); } +/** Apply an alias-field edit and discard hidden native-alias metadata once it becomes ordinary. */ +export function updateComboAliasDraft(item: ComboItem, rawAlias: string): ComboItem { + const trimmed = rawAlias.trim(); + const leavesNativeAliasFamily = item.nativeAlias + && (!trimmed || trimmed.includes("/") || !NATIVE_OPENAI_FAMILY_RE.test(trimmed)); + return { + ...item, + alias: trimmed ? rawAlias : null, + model: comboPublicModelId(item.id, rawAlias), + ...(leavesNativeAliasFamily ? { nativeAlias: false, displayName: null } : {}), + }; +} + function normalizeAlias(raw: unknown): string | null { return typeof raw === "string" && raw.trim() ? raw.trim() : null; } @@ -151,6 +172,8 @@ export function parseComboList(payload: unknown): ComboItem[] { ? r.model.trim() : comboPublicModelId(id, normalizeAlias(r.alias)), alias: normalizeAlias(r.alias), + nativeAlias: r.nativeAlias === true, + displayName: normalizeAlias(r.displayName), strategy: normalizeStrategy(r.strategy), stickyLimit: normalizeStickyLimit(r.stickyLimit), defaultEffort: normalizeDefaultEffort(r.defaultEffort), @@ -208,6 +231,8 @@ export function draftEquals(a: ComboItem, b: ComboItem): boolean { if ( a.id !== b.id || a.alias !== b.alias + || a.nativeAlias !== b.nativeAlias + || a.displayName !== b.displayName || a.strategy !== b.strategy || a.stickyLimit !== b.stickyLimit || a.defaultEffort !== b.defaultEffort @@ -228,6 +253,8 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} stickyLimit?: number; defaultEffort: ComboEffort | null; alias?: string; + nativeAlias?: true; + displayName?: string; }; } { return { @@ -241,6 +268,8 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} defaultEffort: item.defaultEffort, ...(item.strategy === "round-robin" ? { stickyLimit: item.stickyLimit } : {}), ...(item.alias && item.alias.trim() ? { alias: item.alias.trim() } : {}), + ...(item.nativeAlias ? { nativeAlias: true } : {}), + ...(item.displayName && item.displayName.trim() ? { displayName: item.displayName.trim() } : {}), }, }; } @@ -254,6 +283,9 @@ export type ComboDraftError = | "invalidAlias" | "aliasReservedNamespace" | "aliasNativeFamily" + | "unsupportedNativeAlias" + | "missingNativeAliasDisplayName" + | "invalidDisplayName" | "duplicateAlias" | "noTargets" | "incompleteTarget" @@ -283,12 +315,23 @@ export function validateComboDraft( if (Object.hasOwn(options.providers, id)) return "providerCollision"; const alias = item.alias?.trim() ?? ""; + const displayName = item.displayName?.trim() ?? ""; if (alias) { if (!COMBO_ALIAS_RE.test(alias)) return "invalidAlias"; if (alias === "combo" || alias.startsWith("combo/")) return "aliasReservedNamespace"; - if (!alias.includes("/") && NATIVE_OPENAI_FAMILY_RE.test(alias)) return "aliasNativeFamily"; + if (!alias.includes("/") && NATIVE_OPENAI_FAMILY_RE.test(alias) && !item.nativeAlias) return "aliasNativeFamily"; if ((options.existingAliases ?? []).includes(alias)) return "duplicateAlias"; } + const displayNameHasControlCharacter = [...(item.displayName ?? "")].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }); + if (item.displayName !== null + && (displayName.length > 128 || displayNameHasControlCharacter)) { + return "invalidDisplayName"; + } + if (item.nativeAlias && !SUPPORTED_NATIVE_OPENAI_SLUGS.has(alias)) return "unsupportedNativeAlias"; + if (item.nativeAlias && !displayName) return "missingNativeAliasDisplayName"; if (item.targets.length < 1) return "noTargets"; for (const t of item.targets) { @@ -324,6 +367,8 @@ export function emptyDraft(id = ""): ComboItem { id, model: id ? comboModelId(id) : "combo/", alias: null, + nativeAlias: false, + displayName: null, strategy: "failover", stickyLimit: 1, defaultEffort: null, diff --git a/gui/src/components/combo-workspace-detail-panel.tsx b/gui/src/components/combo-workspace-detail-panel.tsx index 4e2cf180ae..1c6f9e0593 100644 --- a/gui/src/components/combo-workspace-detail-panel.tsx +++ b/gui/src/components/combo-workspace-detail-panel.tsx @@ -5,6 +5,7 @@ import { comboPublicModelId, draftEquals, intersectComboEfforts, + updateComboAliasDraft, validateComboDraft, } from "../combo-workspace-data"; import { IconChevron, IconTrash } from "../icons"; @@ -80,7 +81,7 @@ export function DetailPanel({ const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); const [copied, setCopied] = useState(false); const dirty = !draftEquals(draft, baseline); - const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`; + const baselineSyncKey = `${baseline.id}:${baseline.alias ?? ""}:${baseline.nativeAlias}:${baseline.displayName ?? ""}:${baseline.strategy}:${baseline.stickyLimit}:${baseline.defaultEffort}:${baseline.targets.map((t) => `${t.provider}/${t.model}:${t.weight ?? 1}`).join(",")}`; const effortMap = useMemo(() => { const map = new Map(); for (const model of models) { @@ -134,10 +135,12 @@ export function DetailPanel({ setBusy(true); const trimmedId = draft.id.trim(); const alias = draft.alias?.trim() || null; + const displayName = draft.displayName?.trim() || null; const item = { ...draft, id: trimmedId, alias, + displayName, model: comboPublicModelId(trimmedId, alias), }; const renameFrom = !isCreate && trimmedId !== baseline.id ? baseline.id : undefined; @@ -182,7 +185,7 @@ export function DetailPanel({ {t("common.remove")} )} - @@ -261,16 +264,40 @@ export function DetailPanel({ value={draft.alias ?? ""} placeholder={comboModelId(draft.id.trim() || "…")} disabled={busy} - onChange={(e) => updateDraft((d) => ({ - ...d, - alias: e.target.value.trim() ? e.target.value : null, - model: comboPublicModelId(d.id, e.target.value), - }))} + onChange={(e) => updateDraft((d) => updateComboAliasDraft(d, e.target.value))} />

{t("cws.field.aliasHint")}

+
+ +

+ {t("cws.field.nativeAliasHint")} +

+
+
+ + updateDraft((d) => ({ ...d, displayName: e.target.value || null }))} + /> +

+ {t("cws.field.displayNameHint")} +

+
{t("cws.strategy")} = { "cws.field.alias": "Öffentlicher Modellname", "cws.field.aliasPlaceholder": "deepseek-v4-flash oder vendor/model", "cws.field.aliasHint": "Optional. Verwenden Sie einen Namen ohne Präfix, ein eigenes Präfix wie vendor/model oder lassen Sie das Feld leer für combo/.", + "cws.field.nativeAlias": "Natives OpenAI-Alias", + "cws.field.nativeAliasHint": "Lässt diese Combo eine unterstützte unqualifizierte native OpenAI-Modell-ID übernehmen. Konto- und providerqualifizierte OpenAI-Routen bleiben getrennt.", + "cws.field.displayName": "Anzeigename", + "cws.field.displayNameHint": "Bezeichnung im Modell-Picker. Erforderlich, wenn das native OpenAI-Alias aktiviert ist.", "cws.field.idHint": "Clients fordern {model} an", "cws.field.idInternalHint": "Interne Combo-ID. Sie kann nach dem Erstellen geändert werden.", "cws.field.stickyLimit": "Sticky-Erfolge vor Rotation", @@ -1777,6 +1781,9 @@ export const de: Record = { "cws.err.invalidAlias": "Der Alias darf nur Buchstaben, Zahlen, Punkte, Unterstriche oder Bindestriche enthalten, mit höchstens einem \"/\"-Segment.", "cws.err.aliasReservedNamespace": "Der Alias darf den reservierten Namensraum \"combo/\" nicht verwenden.", "cws.err.aliasNativeFamily": "Einfache Aliase aus der OpenAI-nativen Familie (gpt-*, o1-*, o3-*, o4-*, codex-*) sind nicht erlaubt.", + "cws.err.unsupportedNativeAlias": "Ein nativer Alias muss eine derzeit unterstützte, unqualifizierte OpenAI-Modell-ID sein.", + "cws.err.missingNativeAliasDisplayName": "Für native Aliase ist ein Anzeigename erforderlich.", + "cws.err.invalidDisplayName": "Der Anzeigename darf höchstens 128 Zeichen und keine Steuerzeichen enthalten.", "cws.err.duplicateAlias": "Eine andere Combo verwendet diesen Alias bereits.", "cws.err.noTargets": "Mindestens ein Ziel hinzufügen.", "cws.err.incompleteTarget": "Jedes Ziel braucht Anbieter und Modell.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 529ec4d344..bab52b462f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1776,6 +1776,10 @@ export const en = { "cws.field.alias": "Public model name", "cws.field.aliasPlaceholder": "deepseek-v4-flash or vendor/model", "cws.field.aliasHint": "Optional. Use a bare name with no prefix, a custom prefix like vendor/model, or leave blank to use combo/.", + "cws.field.nativeAlias": "Native OpenAI alias", + "cws.field.nativeAliasHint": "Let this combo own a supported unqualified native OpenAI model id. Account- and provider-qualified OpenAI routes stay separate.", + "cws.field.displayName": "Display name", + "cws.field.displayNameHint": "Picker label for this combo. Required when Native OpenAI alias is enabled.", "cws.field.stickyLimit": "Sticky successes before rotate", "cws.field.stickyLimitHint": "Retain the selected target for this many successful requests before the weighted selector advances.", "cws.field.defaultEffort": "Default reasoning", @@ -1811,6 +1815,9 @@ export const en = { "cws.err.invalidAlias": "Alias must use letters, numbers, dots, underscores, or hyphens, with at most one \"/\" segment.", "cws.err.aliasReservedNamespace": "The alias must not use the reserved \"combo/\" namespace.", "cws.err.aliasNativeFamily": "Bare aliases in the OpenAI native family (gpt-*, o1-*, o3-*, o4-*, codex-*) are not allowed.", + "cws.err.unsupportedNativeAlias": "Native alias must be a currently supported bare OpenAI model id.", + "cws.err.missingNativeAliasDisplayName": "A display name is required for native aliases.", + "cws.err.invalidDisplayName": "Display name must be at most 128 characters and contain no control characters.", "cws.err.duplicateAlias": "Another combo already uses this alias.", "cws.err.noTargets": "Add at least one target.", "cws.err.incompleteTarget": "Each target needs a provider and model.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 89d9db9c3d..c283f1d116 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1810,6 +1810,10 @@ export const ja: Record = { "cws.field.alias": "公開モデル名", "cws.field.aliasPlaceholder": "deepseek-v4-flash または vendor/model", "cws.field.aliasHint": "任意。プレフィックスなしの名前、vendor/model のようなカスタムプレフィックスを指定するか、空欄のままにすると combo/ を使用します。", + "cws.field.nativeAlias": "ネイティブ OpenAI エイリアス", + "cws.field.nativeAliasHint": "このコンボがサポート対象の修飾なし OpenAI ネイティブモデル ID を所有します。アカウント修飾・プロバイダー修飾ルートは別のままです。", + "cws.field.displayName": "表示名", + "cws.field.displayNameHint": "モデルピッカーに表示するラベルです。ネイティブ OpenAI エイリアスでは必須です。", "cws.field.stickyLimit": "ローテーション前の固定成功数", "cws.field.stickyLimitHint": "重み付きセレクタが進む前に、選んだターゲットをこの回数の成功リクエスト分保持します。", "cws.field.defaultEffort": "デフォルトの推論", @@ -1845,6 +1849,9 @@ export const ja: Record = { "cws.err.invalidAlias": "エイリアスには英字、数字、ドット、アンダースコア、ハイフンを使用でき、スラッシュ区切りは 1 つまでです。", "cws.err.aliasReservedNamespace": "エイリアスに予約済みの \"combo/\" 名前空間は使用できません。", "cws.err.aliasNativeFamily": "OpenAI ネイティブファミリー(gpt-*、o1-*、o3-*、o4-*、codex-*)のプレフィックスなしエイリアスは使用できません。", + "cws.err.unsupportedNativeAlias": "ネイティブエイリアスには、現在サポートされている OpenAI の bare model id を指定してください。", + "cws.err.missingNativeAliasDisplayName": "ネイティブエイリアスには表示名が必要です。", + "cws.err.invalidDisplayName": "表示名は 128 文字以内で、制御文字を含めることはできません。", "cws.err.duplicateAlias": "別のコンボがすでにこのエイリアスを使用しています。", "cws.err.noTargets": "少なくとも 1 つのターゲットを追加してください。", "cws.err.incompleteTarget": "各ターゲットにはプロバイダーとモデルが必要です。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 47ef2b5ded..b469e5ad7d 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1767,6 +1767,10 @@ export const ko: Record = { "cws.field.alias": "공개 모델 이름", "cws.field.aliasPlaceholder": "deepseek-v4-flash 또는 vendor/model", "cws.field.aliasHint": "선택 사항입니다. 접두사 없는 이름, vendor/model 같은 사용자 지정 접두사를 사용하거나 비워 두어 combo/를 사용할 수 있습니다.", + "cws.field.nativeAlias": "네이티브 OpenAI 별칭", + "cws.field.nativeAliasHint": "이 콤보가 지원되는 비수식 OpenAI 네이티브 모델 ID를 사용합니다. 계정/프로바이더 수식 OpenAI 경로는 별도로 유지됩니다.", + "cws.field.displayName": "표시 이름", + "cws.field.displayNameHint": "모델 선택기에 표시할 이름입니다. 네이티브 OpenAI 별칭을 사용할 때 필수입니다.", "cws.field.idHint": "클라이언트는 {model}을(를) 요청합니다", "cws.field.idInternalHint": "내부 콤보 ID입니다. 생성 후에도 변경할 수 있습니다.", "cws.field.stickyLimit": "회전 전 sticky 성공 횟수", @@ -1804,6 +1808,9 @@ export const ko: Record = { "cws.err.invalidAlias": "별칭은 문자·숫자·점·밑줄·하이픈만 사용할 수 있으며 \"/\" 구분은 최대 한 번만 허용됩니다.", "cws.err.aliasReservedNamespace": "별칭은 예약된 \"combo/\" 네임스페이스를 사용할 수 없습니다.", "cws.err.aliasNativeFamily": "OpenAI 네이티브 계열(gpt-*, o1-*, o3-*, o4-*, codex-*)의 접두사 없는 별칭은 허용되지 않습니다.", + "cws.err.unsupportedNativeAlias": "네이티브 별칭은 현재 지원되는 OpenAI bare model id 중 하나여야 합니다.", + "cws.err.missingNativeAliasDisplayName": "네이티브 별칭에는 표시 이름이 필요합니다.", + "cws.err.invalidDisplayName": "표시 이름은 128자 이하여야 하며 제어 문자를 포함할 수 없습니다.", "cws.err.duplicateAlias": "다른 콤보가 이미 이 별칭을 사용하고 있습니다.", "cws.err.noTargets": "대상을 하나 이상 추가하세요.", "cws.err.incompleteTarget": "각 대상에 프로바이더와 모델이 필요합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 435ddcd1e0..503d50111d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1852,6 +1852,10 @@ export const ru: Record = { "cws.field.alias": "Публичное имя модели", "cws.field.aliasPlaceholder": "deepseek-v4-flash или vendor/model", "cws.field.aliasHint": "Необязательно. Используйте имя без префикса, собственный префикс вроде vendor/model или оставьте поле пустым для combo/.", + "cws.field.nativeAlias": "Нативный псевдоним OpenAI", + "cws.field.nativeAliasHint": "Разрешает комбо занимать поддерживаемый неквалифицированный ID нативной модели OpenAI. Маршруты OpenAI с аккаунтом или провайдером остаются отдельными.", + "cws.field.displayName": "Отображаемое имя", + "cws.field.displayNameHint": "Подпись в списке моделей. Обязательна для нативного псевдонима OpenAI.", "cws.field.stickyLimit": "Успешных запросов до ротации", "cws.field.stickyLimitHint": "Выбранная цель удерживается на указанное число успешных запросов, прежде чем взвешенный селектор перейдёт к следующей.", "cws.field.defaultEffort": "Рассуждения по умолчанию", @@ -1887,6 +1891,9 @@ export const ru: Record = { "cws.err.invalidAlias": "Алиас должен содержать только буквы, цифры, точки, подчёркивания и дефисы, максимум с одним сегментом \"/\".", "cws.err.aliasReservedNamespace": "Алиас не должен использовать зарезервированное пространство имён \"combo/\".", "cws.err.aliasNativeFamily": "Алиасы без префикса из нативного семейства OpenAI (gpt-*, o1-*, o3-*, o4-*, codex-*) недопустимы.", + "cws.err.unsupportedNativeAlias": "Нативный алиас должен быть одним из поддерживаемых сейчас неквалифицированных id моделей OpenAI.", + "cws.err.missingNativeAliasDisplayName": "Для нативного алиаса требуется отображаемое имя.", + "cws.err.invalidDisplayName": "Отображаемое имя должно содержать не более 128 символов и не иметь управляющих символов.", "cws.err.duplicateAlias": "Другое комбо уже использует этот алиас.", "cws.err.noTargets": "Добавьте хотя бы одну цель.", "cws.err.incompleteTarget": "Для каждой цели нужно указать провайдера и модель.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index a45ae10c05..155f47a660 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1762,6 +1762,10 @@ export const zh: Record = { "cws.field.alias": "公开模型名称", "cws.field.aliasPlaceholder": "deepseek-v4-flash 或 vendor/model", "cws.field.aliasHint": "可选。可填无前缀裸名称、自定义前缀(如 vendor/model),或留空使用 combo/。", + "cws.field.nativeAlias": "原生 OpenAI 别名", + "cws.field.nativeAliasHint": "允许此组合接管受支持的未限定 OpenAI 原生模型 ID。带账户或提供商限定的 OpenAI 路由仍保持独立。", + "cws.field.displayName": "显示名称", + "cws.field.displayNameHint": "模型选择器中的标签。启用原生 OpenAI 别名时必填。", "cws.field.stickyLimit": "轮换前的粘性成功次数", "cws.field.stickyLimitHint": "加权选择器推进前,将所选目标保留这么多次成功请求。", "cws.field.defaultEffort": "默认推理级别", @@ -1797,6 +1801,9 @@ export const zh: Record = { "cws.err.invalidAlias": "别名仅可包含字母、数字、点、下划线或连字符,最多一个“/”分段。", "cws.err.aliasReservedNamespace": "别名不得使用保留的“combo/”命名空间。", "cws.err.aliasNativeFamily": "不允许使用 OpenAI 原生家族裸别名(gpt-*、o1-*、o3-*、o4-*、codex-*)。", + "cws.err.unsupportedNativeAlias": "原生别名必须是当前受支持的 OpenAI 裸 model id。", + "cws.err.missingNativeAliasDisplayName": "原生别名必须提供显示名称。", + "cws.err.invalidDisplayName": "显示名称最多 128 个字符,且不能包含控制字符。", "cws.err.duplicateAlias": "另一个组合已使用该别名。", "cws.err.noTargets": "至少添加一个目标。", "cws.err.incompleteTarget": "每个目标都需要提供方和模型。", diff --git a/gui/tests/combo-native-alias-editor.test.tsx b/gui/tests/combo-native-alias-editor.test.tsx new file mode 100644 index 0000000000..2303b5d735 --- /dev/null +++ b/gui/tests/combo-native-alias-editor.test.tsx @@ -0,0 +1,169 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import type { ComboItem } from "../src/combo-workspace-data"; +import { DetailPanel } from "../src/components/combo-workspace-detail-panel"; +import { LanguageProvider } from "../src/i18n/provider"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobalDescriptors: Record< + (typeof globals)[number], + PropertyDescriptor | undefined +>; +let testWindow: Window; + +beforeEach(() => { + previousGlobalDescriptors = Object.fromEntries( + globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]), + ) as typeof previousGlobalDescriptors; + testWindow = new Window({ url: "http://localhost/" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) { + const descriptor = previousGlobalDescriptors[key]; + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } +}); + +async function flushTimers() { + await act(async () => { await new Promise(resolve => testWindow.setTimeout(resolve, 0)); }); +} + +function setInputValue(input: HTMLInputElement, value: string) { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); +} + +const target = [{ provider: "openai", model: "gpt-5", clientKey: "ct-native" }]; +const providers = [{ name: "openai" }]; +const models = [{ provider: "openai", id: "gpt-5" }]; + +async function renderPanel(baseline: ComboItem, isCreate: boolean, onSave: (item: ComboItem) => void) { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + document.body.append(container); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( + + {}} + onSave={async item => { onSave(item); return { ok: true }; }} + onDirtyChange={() => {}} + /> + , + ); + }); + await flushTimers(); + return { container, root }; +} + +test("create exposes and saves nativeAlias plus displayName", async () => { + const baseline: ComboItem = { + id: "nova", + model: "combo/nova", + alias: null, + nativeAlias: false, + displayName: null, + strategy: "failover", + stickyLimit: 1, + defaultEffort: null, + targets: target, + }; + let saved: ComboItem | undefined; + const { container, root } = await renderPanel(baseline, true, item => { saved = item; }); + const alias = container.querySelector("#cwi-edit-alias")!; + const nativeAlias = container.querySelector("#cwi-edit-native-alias")!; + const displayName = container.querySelector("#cwi-edit-display-name")!; + expect(nativeAlias).toBeTruthy(); + expect(displayName).toBeTruthy(); + await act(async () => { + setInputValue(alias, "gpt-5.6-sol"); + nativeAlias.click(); + }); + const displayNameAfterToggle = container.querySelector("#cwi-edit-display-name")!; + await act(async () => { setInputValue(displayNameAfterToggle, "Nova Sol"); }); + const create = container.querySelector("#cwi-edit-create"); + expect(create).toBeTruthy(); + await act(async () => { create!.click(); }); + expect(saved).toMatchObject({ alias: "gpt-5.6-sol", nativeAlias: true, displayName: "Nova Sol" }); + await act(async () => root.unmount()); + container.remove(); +}); + +test("edit renders existing nativeAlias state and persists display-name changes", async () => { + const baseline: ComboItem = { + id: "nova", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova Sol", + strategy: "failover", + stickyLimit: 1, + defaultEffort: null, + targets: target, + }; + let saved: ComboItem | undefined; + const { container, root } = await renderPanel(baseline, false, item => { saved = item; }); + const nativeAlias = container.querySelector("#cwi-edit-native-alias")!; + const displayName = container.querySelector("#cwi-edit-display-name")!; + expect(nativeAlias.checked).toBe(true); + expect(displayName.value).toBe("Nova Sol"); + await act(async () => { setInputValue(displayName, "Nova Sol 2"); }); + const save = container.querySelector("#cwi-edit-save"); + expect(save).toBeTruthy(); + await act(async () => { save!.click(); }); + expect(saved).toMatchObject({ nativeAlias: true, displayName: "Nova Sol 2" }); + await act(async () => root.unmount()); + container.remove(); +}); + + +test("edit clears native-alias metadata when alias leaves native family", async () => { + const baseline: ComboItem = { + id: "nova", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova Sol", + strategy: "failover", + stickyLimit: 1, + defaultEffort: null, + targets: target, + }; + let saved: ComboItem | undefined; + const { container, root } = await renderPanel(baseline, false, item => { saved = item; }); + const alias = container.querySelector("#cwi-edit-alias")!; + await act(async () => { setInputValue(alias, "vendor/custom"); }); + const save = container.querySelector("#cwi-edit-save"); + expect(save).toBeTruthy(); + await act(async () => { save!.click(); }); + expect(saved).toMatchObject({ + alias: "vendor/custom", + model: "vendor/custom", + nativeAlias: false, + displayName: null, + }); + await act(async () => root.unmount()); + container.remove(); +}); \ No newline at end of file diff --git a/gui/tests/combo-workspace-dirty.test.tsx b/gui/tests/combo-workspace-dirty.test.tsx index 6df8cb7956..c2b0e10f13 100644 --- a/gui/tests/combo-workspace-dirty.test.tsx +++ b/gui/tests/combo-workspace-dirty.test.tsx @@ -15,6 +15,8 @@ const combos: ComboItem[] = [ id: "alpha", model: "combo/alpha", alias: null, + nativeAlias: false, + displayName: null, strategy: "failover", stickyLimit: 1, defaultEffort: null, @@ -24,6 +26,8 @@ const combos: ComboItem[] = [ id: "beta", model: "combo/beta", alias: null, + nativeAlias: false, + displayName: null, strategy: "failover", stickyLimit: 1, defaultEffort: null, diff --git a/src/cli/combo.ts b/src/cli/combo.ts index ca2763849e..6a4bdc6ac1 100644 --- a/src/cli/combo.ts +++ b/src/cli/combo.ts @@ -16,6 +16,7 @@ const USAGE = `Usage: ocx combo set --targets [--strategy ] [--sticky <1-100>] [--effort ] [--alias ] + [--native-alias] [--display-name ] [--rename-from ] [--json] ocx combo remove --yes [--json]`; @@ -77,6 +78,8 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { if (stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE); const effort = takeOption(args, "--effort"); const alias = takeOption(args, "--alias"); + const nativeAlias = takeFlag(args, "--native-alias"); + const displayName = takeOption(args, "--display-name"); const renameFrom = takeOption(args, "--rename-from"); rejectArgs(args, USAGE); const combo: Record = { @@ -86,6 +89,8 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise { }; if (effort !== undefined) combo.defaultEffort = effort === "-" ? null : effort; if (alias !== undefined) combo.alias = alias === "-" ? "" : alias; + if (nativeAlias) combo.nativeAlias = true; + if (displayName !== undefined) combo.displayName = displayName === "-" ? "" : displayName; const result = await runtimeRequest("/api/combos", { method: "PUT", body: JSON.stringify({ id, combo, ...(renameFrom ? { renameFrom } : {}) }), diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index 8a98f0ff73..db98debccf 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -2,7 +2,7 @@ // Public surface preserved exactly; importers keep using "src/codex/catalog". export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing"; export type { CatalogModel, MultiAgentMode } from "./catalog/parsing"; -export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata"; +export { CODEX_NATIVE_ALIAS_CATALOG_KIND, NATIVE_OPENAI_MODELS, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, isNativeAliasCatalogEntry, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi } from "./catalog/metadata"; export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled"; export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort"; export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithMetadata } from "./catalog/provider-fetch"; diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index cb1d9fd07f..a4af95aa9c 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -20,6 +20,7 @@ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models"; import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers"; import { COMBO_NAMESPACE, + comboDisabledModelSelectors, comboModelId, getCombo, listComboIds, @@ -135,6 +136,14 @@ export function deriveComboCatalogModel( members.map(member => member.reasoningEfforts ?? []), ); const contextWindow = Math.min(...members.map(member => member.contextWindow!)); + const limitingMembers = members.filter(member => member.contextWindow === contextWindow); + const hasLimitingContextCapMetadata = limitingMembers.some( + member => typeof member.contextCapped === "boolean", + ); + // A combo is cap-limited only when every member defining its effective minimum was + // itself reduced by a provider cap. An uncapped member at the same minimum means the + // combo would have the same window even without the cap. + const contextCapped = limitingMembers.every(member => member.contextCapped === true); const maxInputTokens = Math.min( ...members.map(member => member.maxInputTokens ?? member.contextWindow!), ); @@ -149,9 +158,12 @@ export function deriveComboCatalogModel( owned_by: COMBO_NAMESPACE, contextWindow, maxInputTokens, + ...(hasLimitingContextCapMetadata ? { contextCapped } : {}), inputModalities, reasoningEfforts, ...(combo.alias ? { alias: combo.alias } : {}), + ...(combo.nativeAlias ? { nativeAlias: true } : {}), + ...(combo.displayName ? { displayName: combo.displayName } : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(members.every(member => member.parallelToolCalls === true) ? { parallelToolCalls: true } @@ -252,12 +264,15 @@ export function exactComboCatalogSlugs( ): Set { const disabled = new Set(config.disabledModels ?? []); return new Set(listComboIds(config).flatMap(id => { - const alias = typeof config.combos?.[id]?.alias === "string" - ? config.combos[id]!.alias!.trim() + const raw = config.combos?.[id]; + const alias = typeof raw?.alias === "string" + ? raw.alias.trim() : ""; const canonical = comboModelId(id); const publicSlug = alias || canonical; - return disabled.has(publicSlug) || disabled.has(canonical) ? [] : [publicSlug]; + const comboDisabled = comboDisabledModelSelectors(id, raw ?? {}) + .some(selector => disabled.has(selector)); + return comboDisabled ? [] : [publicSlug]; })); } diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index e68f61a7df..3a7f823d5c 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -116,10 +116,9 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel) if (model.provider === COMBO_NAMESPACE) entry.owned_by = model.owned_by ?? COMBO_NAMESPACE; // displayName is DISPLAY-ONLY: it relabels the picker row but never touches the routing // slug, alias, or provider. deriveEntry already stamped the slug as display_name; a - // configured displayName overrides just the label. The `/` separator is rejected at every - // input boundary (CLI `ocx models add`, management API), so the catalog trusts its source. - // Combos carry no displayName, and natives never reach here (no CatalogModel), so genuine - // upstream marketing names and combo alias labels are preserved untouched. + // configured displayName overrides just the label. Custom-model inputs reject `/`; combos + // validate their bounded display label independently. Natives never reach here (no CatalogModel), + // so genuine upstream marketing names are preserved untouched. const displayName = typeof model.displayName === "string" ? model.displayName.trim() : ""; if (displayName) entry.display_name = displayName; if (typeof model.contextWindow === "number" && model.contextWindow > 0) { diff --git a/src/codex/catalog/kinds.ts b/src/codex/catalog/kinds.ts new file mode 100644 index 0000000000..db084d0ba2 --- /dev/null +++ b/src/codex/catalog/kinds.ts @@ -0,0 +1,2 @@ +/** Stable ownership marker for a routed combo that deliberately owns a bare native slug. */ +export const CODEX_NATIVE_ALIAS_CATALOG_KIND = "combo-native-alias-v1"; diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index fee12f5223..c5550bf373 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -22,6 +22,7 @@ import { COMBO_NAMESPACE, comboModelId, getCombo, + isNativeAliasCombo, listComboIds, targetKey, } from "../../combos"; @@ -34,18 +35,48 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import type { RawEntry } from "./parsing"; import { readCurrentCatalogOrCache, unique } from "./bundled"; import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; - -export const NATIVE_OPENAI_MODELS = [ - "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", - "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", -]; +import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; +import { NATIVE_OPENAI_MODELS, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./native-models"; +export { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; +export { NATIVE_OPENAI_MODELS, SUPPORTED_NATIVE_OPENAI_SLUGS } from "./native-models"; export const DOCUMENTED_NATIVE_OPENAI_ADDITIONS = [ "gpt-5.3-codex-spark", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", ]; -export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS); +export function configuredNativeAliasSlugs( + config: Pick, +): Set { + const aliases = new Set(); + for (const raw of Object.values(config.combos ?? {})) { + if (!isNativeAliasCombo(raw)) continue; + const alias = raw.alias!.trim(); + if (SUPPORTED_NATIVE_OPENAI_SLUGS.has(alias)) aliases.add(alias); + } + return aliases; +} + +/** + * Bare native rows that must be absent, rather than merely hidden, while Desktop native-alias + * compatibility is active. Codex Desktop's remote allowlist can ignore `visibility: "hide"`; + * omitting disabled native rows is therefore part of the explicit native-alias opt-in. + */ +export function desktopAllowlistSuppressedNativeSlugs( + config: Pick, +): Set { + const suppressed = configuredNativeAliasSlugs(config); + if (suppressed.size === 0) return suppressed; + const disabled = disabledNativeSlugs(config); + for (const slug of NATIVE_OPENAI_MODELS) { + if (disabled.has(slug)) suppressed.add(slug); + } + return suppressed; +} + +export function isNativeAliasCatalogEntry(entry: RawEntry): boolean { + return entry.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND; +} export function isUnsupportedOpenAiNativeSlug(slug: string): boolean { if (slug.includes("/")) return false; @@ -64,15 +95,27 @@ export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record = new Map( + ((upstreamModelsSnapshot as unknown as { models?: RawEntry[] }).models ?? []) + .filter(m => typeof m.slug === "string" + && SUPPORTED_NATIVE_OPENAI_SLUGS.has(m.slug as string)) + .map(m => [m.slug as string, m]), +); + export function nativeOpenAiContextWindow(slug: string): number | undefined { return NATIVE_OPENAI_CONTEXT_OVERRIDES[slug]?.contextWindow - ?? (typeof UPSTREAM_NATIVE_ENTRIES.get(slug)?.context_window === "number" - ? UPSTREAM_NATIVE_ENTRIES.get(slug)!.context_window as number + ?? (typeof PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug)?.context_window === "number" + ? PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug)!.context_window as number : undefined); } export function nativeInputModalities(slug: string): string[] { - const upstream = UPSTREAM_NATIVE_ENTRIES.get(slug); + const upstream = PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug); if (Array.isArray(upstream?.input_modalities) && upstream!.input_modalities!.length > 0) { return [...upstream!.input_modalities as string[]]; } @@ -82,7 +125,7 @@ export function nativeInputModalities(slug: string): string[] { } export function nativeReasoningEfforts(slug: string): string[] { - const upstream = UPSTREAM_NATIVE_ENTRIES.get(slug); + const upstream = PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug); const levels = Array.isArray(upstream?.supported_reasoning_levels) ? upstream!.supported_reasoning_levels as Array<{ effort?: string }> : []; @@ -97,12 +140,18 @@ export function nativeReasoningEfforts(slug: string): string[] { /** Upstream-pinned default for a native slug, when present and non-empty. */ export function nativeDefaultReasoningEffort(slug: string): string | undefined { - const level = UPSTREAM_NATIVE_ENTRIES.get(slug)?.default_reasoning_level; + const level = PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug)?.default_reasoning_level; return typeof level === "string" && level.length > 0 ? level : undefined; } +/** Upstream-pinned multi-agent surface for a supported native slug, when present. */ +export function nativeMultiAgentVersion(slug: string): string | undefined { + const version = PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug)?.multi_agent_version; + return typeof version === "string" && version.length > 0 ? version : undefined; +} + export function nativeParallelToolCalls(slug: string): boolean { - return UPSTREAM_NATIVE_ENTRIES.get(slug)?.supports_parallel_tool_calls === true + return PINNED_NATIVE_CAPABILITY_ENTRIES.get(slug)?.supports_parallel_tool_calls === true || false; } @@ -116,9 +165,10 @@ export function disabledNativeSlugs(config: Pick): return new Set((config.disabledModels ?? []).filter(id => !id.includes("/"))); } -export function visibleNativeSlugs(config: Pick): string[] { +export function visibleNativeSlugs(config: Pick): string[] { const disabled = disabledNativeSlugs(config); - return nativeOpenAiSlugs().filter(slug => !disabled.has(slug)); + const shadowed = configuredNativeAliasSlugs(config); + return nativeOpenAiSlugs().filter(slug => !disabled.has(slug) && !shadowed.has(slug)); } /** Whether an enabled canonical OpenAI provider can serve exact account-qualified routes. */ @@ -144,14 +194,15 @@ export function shouldIncludeNativeOpenAi(config: Pick): } /** Native slugs exposed to Claude Desktop show/export/apply (opt-out via claudeCode.desktopNativeModels). */ -export function desktopVisibleNativeSlugs(config: Pick): string[] { +export function desktopVisibleNativeSlugs(config: Pick): string[] { if (config.claudeCode?.desktopNativeModels === false) return []; return visibleNativeSlugs(config); } -export function nativeModelRows(config: Pick): Array<{ slug: string; disabled: boolean; contextWindow?: number }> { +export function nativeModelRows(config: Pick): Array<{ slug: string; disabled: boolean; contextWindow?: number }> { const disabled = disabledNativeSlugs(config); - return NATIVE_OPENAI_MODELS.map(slug => { + const shadowed = configuredNativeAliasSlugs(config); + return NATIVE_OPENAI_MODELS.filter(slug => !shadowed.has(slug)).map(slug => { const contextWindow = nativeOpenAiContextWindow(slug); return { slug, disabled: disabled.has(slug), ...(contextWindow !== undefined ? { contextWindow } : {}) }; }); @@ -163,6 +214,7 @@ export function applyNativeVisibility( hideBareNative = false, ): RawEntry[] { for (const entry of entries) { + if (isNativeAliasCatalogEntry(entry)) continue; const slug = typeof entry.slug === "string" ? entry.slug : ""; const accountBoundSlug = trustedAccountBoundNativeCatalogSlug(entry); const nativeSlug = accountBoundSlug ?? slug; diff --git a/src/codex/catalog/native-models.ts b/src/codex/catalog/native-models.ts new file mode 100644 index 0000000000..b4076ef62d --- /dev/null +++ b/src/codex/catalog/native-models.ts @@ -0,0 +1,7 @@ +/** Native OpenAI model ids that this release can route and restore with authoritative metadata. */ +export const NATIVE_OPENAI_MODELS = [ + "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark", + "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", +]; + +export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS); diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index fa0028759a..952bb09a82 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -31,8 +31,9 @@ import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; -import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES } from "./metadata"; +import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES, nativeMultiAgentVersion } from "./metadata"; import { trustedAccountBoundNativeCatalogSlug } from "./account-models"; +import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "./kinds"; export function legacyCatalogBackupPath(): string { return join(getConfigDir(), "catalog-backup.json"); @@ -95,6 +96,8 @@ export interface CatalogModel { provider: string; /** Public Codex-facing slug override (used by combo aliases). */ alias?: string; + /** Explicit combo takeover of a bare OpenAI-native catalog id. */ + nativeAlias?: boolean; /** * Display-only Codex catalog `display_name` override. Relabels the picker row ONLY — it never * affects the routing slug, alias-collision order, native marketing-name precedence, or provider @@ -200,7 +203,12 @@ export function readCatalog(path: string): RawCatalog | null { export function findNativeTemplate(catalog: RawCatalog | null): RawEntry | null { return catalog?.models?.find( - m => typeof m.slug === "string" && !m.slug.includes("/") && "base_instructions" in m, + m => typeof m.slug === "string" + && !m.slug.includes("/") + && "base_instructions" in m + && m.opencodex_catalog_kind !== CODEX_NATIVE_ALIAS_CATALOG_KIND + && m.owned_by !== COMBO_NAMESPACE + && !(typeof m.description === "string" && m.description.startsWith("Routed via opencodex → ")), ) ?? null; } @@ -327,8 +335,10 @@ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode, v // re-apply upstream pins from the snapshot for native entries that have one. for (const entry of entries) { const slug = typeof entry.slug === "string" ? entry.slug : ""; - const upstream = UPSTREAM_NATIVE_ENTRIES.get(trustedAccountBoundNativeCatalogSlug(entry) ?? slug); - const upstreamPin = upstream?.multi_agent_version; + const nativeAlias = entry.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND; + const upstreamPin = nativeAlias + ? nativeMultiAgentVersion(slug) + : UPSTREAM_NATIVE_ENTRIES.get(trustedAccountBoundNativeCatalogSlug(entry) ?? slug)?.multi_agent_version; if (typeof upstreamPin === "string") { entry.multi_agent_version = upstreamPin; } else if (v2FeatureEnabled) { @@ -432,7 +442,8 @@ export function readCatalogBackup(catalogPath: string): RawCatalog | null { } export function catalogHasRoutedEntries(catalog: RawCatalog | null): boolean { - return (catalog?.models ?? []).some(m => typeof m.slug === "string" && m.slug.includes("/")); + return (catalog?.models ?? []).some(m => typeof m.slug === "string" + && (m.slug.includes("/") || m.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND)); } export function writePristineCatalogBackup(backupPath: string, catalogPath: string, catalog: RawCatalog): void { diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 79d3d55b90..782ef9cc30 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -64,7 +64,7 @@ import { createAdmissionGate, ResourceAdmissionError, type AdmissionMetrics } fr 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 { disabledNativeSlugs, hasComboTargets, nativeDefaultReasoningEffort, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiSlugs, nativeParallelToolCalls, nativeReasoningEfforts } from "./metadata"; import { deriveComboCatalogModel, normalizedOpenAiApiSignature, openAiApiCollisionWarnings, replaceLastComboCatalogOmissions, warnUncataloguedComboOnce } from "./aggregation"; import type { ComboCatalogOmission } from "./aggregation"; import type { CatalogGatherProviderAuthEvidence } from "./filesystem-evidence"; @@ -1129,11 +1129,14 @@ export function filterCatalogVisibleModels( if (Array.isArray(sel) && sel.length > 0) allowByProvider.set(name, new Set(sel)); } return models.filter(m => { + const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true; // disabledModels may be stored raw (canonical) or encoded (legacy UI writes). for (const stored of disabled) { // Combo management stores the public alias, while canonical `combo/` references // remain valid for backward compatibility through slugEquals below. - if (m.alias !== undefined && stored === catalogModelSlug(m)) return false; + // A native-alias combo deliberately shares its public id with the native row: a bare + // disabledModels value hides that native row, while `combo/` disables the combo. + if (m.alias !== undefined && stored === catalogModelSlug(m) && !nativeAlias) return false; if (slugEquals(stored, m.provider, m.id)) return false; } const allow = allowByProvider.get(m.provider); @@ -1238,6 +1241,50 @@ async function gatherRoutedModelsWithAuth( return models; } +function completeNativeAliasMembers( + combo: NormalizedComboConfig, + members: readonly CatalogModel[], +): CatalogModel[] { + if (!combo.nativeAlias || !combo.alias || members.length !== combo.targets.length) { + return [...members]; + } + const nativeContextWindow = nativeOpenAiContextWindow(combo.alias); + if (nativeContextWindow === undefined) return [...members]; + const nativeModalities = nativeInputModalities(combo.alias); + const nativeEfforts = nativeReasoningEfforts(combo.alias); + return members.map(member => { + const explicitContextWindow = typeof member.contextWindow === "number" && member.contextWindow > 0 + ? member.contextWindow + : undefined; + // Provider rows have already passed through applyProviderConfigHints(), including + // providerContextCap. Preserve that effective window and its provenance instead of + // treating the already-capped value as a fresh uncapped input and applying the cap twice. + const contextWindow = explicitContextWindow + ?? applyProviderContextCap(nativeContextWindow, member.contextCap); + const contextCapped = typeof member.contextCapped === "boolean" + ? member.contextCapped + : explicitContextWindow === undefined && member.contextCap !== undefined && contextWindow !== undefined + ? contextWindow !== nativeContextWindow + : undefined; + const memberMaxInput = typeof member.maxInputTokens === "number" && member.maxInputTokens > 0 + ? member.maxInputTokens + : contextWindow; + const maxInputTokens = contextWindow !== undefined && memberMaxInput !== undefined + ? Math.min(memberMaxInput, contextWindow) + : memberMaxInput; + return { + ...member, + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(maxInputTokens !== undefined ? { maxInputTokens } : {}), + ...(!Array.isArray(member.inputModalities) || member.inputModalities.length === 0 + ? { inputModalities: [...nativeModalities] } + : {}), + ...(member.reasoningEfforts === undefined ? { reasoningEfforts: [...nativeEfforts] } : {}), + ...(contextCapped !== undefined ? { contextCapped } : {}), + }; + }); +} + async function gatherRoutedModelsUncached( config: OcxConfig, capture: GatherFlightCapture, @@ -1299,8 +1346,16 @@ async function gatherRoutedModelsUncached( // configs that will never need it. } else { const disabled = disabledNativeSlugs(config); + const requiredNativeComboTargets = new Set(listComboIds(config).flatMap(id => { + const combo = getCombo(config, id); + return combo?.targets.flatMap(target => ( + target.provider === "openai" ? [target.model] : [] + )) ?? []; + })); for (const slug of nativeOpenAiSlugs()) { - if (disabled.has(slug)) continue; + // A bare native disable key hides the native row, not a combo that targets it. + // Keep synthetic native metadata available to those combos. + if (disabled.has(slug) && !requiredNativeComboTargets.has(slug)) continue; const contextWindow = nativeOpenAiContextWindow(slug); if (contextWindow === undefined) continue; const synthetic: CatalogModel = { @@ -1322,11 +1377,22 @@ async function gatherRoutedModelsUncached( for (const id of listComboIds(config)) { const combo = getCombo(config, id); if (!combo) continue; - const members = combo.targets + const discoveredMembers = combo.targets .map(target => memberByKey.get(targetKey(target))) .filter((member): member is CatalogModel => member !== undefined); + const members = completeNativeAliasMembers(combo, discoveredMembers); const derived = deriveComboCatalogModel(id, combo, members); - if (derived) all.push(derived); + if (derived) { + const nativeDefault = combo.nativeAlias && combo.alias + ? nativeDefaultReasoningEffort(combo.alias) + : undefined; + if (combo.defaultEffort === null + && nativeDefault + && derived.reasoningEfforts?.includes(nativeDefault)) { + derived.defaultReasoningEffort = nativeDefault; + } + all.push(derived); + } else warnUncataloguedComboOnce(id, combo, members, localOmissions); } replaceLastComboCatalogOmissions(localOmissions); diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 1eeb890ff6..fa35ae2475 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -33,7 +33,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; 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 { applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { bundledCatalogCacheState, loadBundledCodexCatalog, @@ -339,6 +339,8 @@ export interface ObservedCatalogEntryBuildInput { readonly multiAgentMode: MultiAgentMode; readonly exactComboSlugs: ReadonlySet; readonly accountSelectors: readonly string[]; + readonly suppressedBareNativeSlugs: ReadonlySet; + readonly disabledNativeAccountSlugs: ReadonlySet; readonly multiAgentV2Enabled: boolean; } @@ -352,6 +354,8 @@ export function buildCatalogEntries( multiAgentMode: MultiAgentMode = "default", exactComboSlugs: ReadonlySet = new Set(), accountSelectors: readonly string[] = [], + suppressedBareNativeSlugs: ReadonlySet = new Set(), + disabledNativeAccountSlugs: ReadonlySet = new Set(), ): RawEntry[] { return buildCatalogEntriesFromObservedState({ template, @@ -362,6 +366,8 @@ export function buildCatalogEntries( multiAgentMode, exactComboSlugs, accountSelectors, + suppressedBareNativeSlugs, + disabledNativeAccountSlugs, multiAgentV2Enabled: isMultiAgentV2Enabled(), }); } @@ -376,6 +382,8 @@ export function buildCatalogEntriesFromObservedState({ multiAgentMode, exactComboSlugs, accountSelectors, + suppressedBareNativeSlugs, + disabledNativeAccountSlugs, multiAgentV2Enabled, }: ObservedCatalogEntryBuildInput): RawEntry[] { // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible @@ -387,26 +395,69 @@ export function buildCatalogEntriesFromObservedState({ const out: RawEntry[] = []; const nativeEntries: RawEntry[] = []; const collisionSkipped = resolveSlugAliasCollisions([...goModels]); + const emittedNativeAliases = new Set(); + const emittedNativeAliasSlugs = new Set(); + const nativeAliasesBySlug = new Map(); + for (const model of goModels) { + if (model.provider !== COMBO_NAMESPACE + || model.nativeAlias !== true + || typeof model.alias !== "string" + || model.alias.includes("/")) continue; + if (nativeAliasesBySlug.has(model.alias)) { + collisionSkipped.add(model); + if (!slugAliasCollisionWarnings.has(model.alias)) { + slugAliasCollisionWarnings.add(model.alias); + console.warn( + `[opencodex] native combo alias collision on "${model.alias}": keeping the first configured combo and omitting later duplicates from the catalog.`, + ); + } + continue; + } + nativeAliasesBySlug.set(model.alias, model); + } const comboPublicSlugs = new Set(goModels .filter(model => model.provider === COMBO_NAMESPACE) .map(catalogModelSlug)); for (const slug of gptSlugs) { - const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9); - if (rank.has(slug)) e.priority = rank.get(slug)!; - out.push(e); - nativeEntries.push(e); + const native = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9); + if (rank.has(slug)) native.priority = rank.get(slug)!; + nativeEntries.push(native); + const nativeAlias = nativeAliasesBySlug.get(slug); + if (!nativeAlias || collisionSkipped.has(nativeAlias)) { + if (!suppressedBareNativeSlugs.has(slug)) out.push(native); + continue; + } + const routed = deriveEntry( + template, + slug, + `Routed via opencodex → ${nativeAlias.provider} (${nativeAlias.owned_by ?? nativeAlias.provider}).`, + 5, + nativeAlias, + exactComboSlugs, + ); + routed.opencodex_catalog_kind = CODEX_NATIVE_ALIAS_CATALOG_KIND; + const rankHit = rank.get(slug) ?? rank.get(`${nativeAlias.provider}/${nativeAlias.id}`); + if (rankHit !== undefined) routed.priority = rankHit * priorityStride; + else if (accountSelectors.length > 0) routed.priority = 1_000 + (typeof routed.priority === "number" ? routed.priority : 5); + out.push(routed); + emittedNativeAliases.add(nativeAlias); + emittedNativeAliasSlugs.add(slug); } for (const [selectorIndex, selector] of accountSelectors.entries()) { for (const [nativeIndex, native] of nativeEntries.entries()) { - const e = JSON.parse(JSON.stringify(native)) as RawEntry; const nativeSlug = String(native.slug); + if (disabledNativeAccountSlugs.has(nativeSlug)) continue; + const e = JSON.parse(JSON.stringify(native)) as RawEntry; const catalogSlug = `${selector}/${nativeSlug}`; e.slug = catalogSlug; e.display_name = accountBoundNativeDisplayName(selector, native); // Codex ignores this OpenCodex extension; preserve the native comp_hash unchanged. e.opencodex_catalog_kind = CODEX_ACCOUNT_BOUND_CATALOG_KIND; const exactRank = rank.get(catalogSlug); - const inheritedRank = rank.get(nativeSlug); + // A bare featured id belongs to the compatibility combo once shadowed. Exact + // account-qualified picks still rank normally, but the account clone must not + // inherit the bare alias rank and consume another top spawn_agent slot. + const inheritedRank = emittedNativeAliasSlugs.has(nativeSlug) ? undefined : rank.get(nativeSlug); const featuredRank = exactRank ?? inheritedRank; e.priority = featuredRank !== undefined ? featuredRank * priorityStride + selectorIndex @@ -416,7 +467,7 @@ export function buildCatalogEntriesFromObservedState({ } } for (const m of goModels) { - if (collisionSkipped.has(m)) continue; + if (collisionSkipped.has(m) || emittedNativeAliases.has(m)) continue; const slug = catalogModelSlug(m); if (m.provider !== COMBO_NAMESPACE && comboPublicSlugs.has(slug)) { warnComboMasqueradeCollisionOnce(slug); @@ -432,6 +483,9 @@ export function buildCatalogEntriesFromObservedState({ m, exactComboSlugs, ); + if (m.provider === COMBO_NAMESPACE && m.nativeAlias === true && !slug.includes("/")) { + e.opencodex_catalog_kind = CODEX_NATIVE_ALIAS_CATALOG_KIND; + } // Featured picks may be stored raw (legacy) or encoded — honor both. const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`); if (rankHit !== undefined) e.priority = rankHit * priorityStride; @@ -494,11 +548,42 @@ export function orderForSubagents(goModels: CatalogModel[], featured?: string[]) * row. */ function isOcxAuthoredRoutedEntry(entry: RawEntry): boolean { + if (isNativeAliasCatalogEntry(entry)) return true; const desc = typeof entry.description === "string" ? entry.description : ""; const slug = typeof entry.slug === "string" ? entry.slug : ""; return slug.includes("/") && desc.startsWith("Routed via opencodex → "); } +function recoverableNativeSlug(entry: RawEntry): string | null { + const slug = typeof entry.slug === "string" ? entry.slug : ""; + return SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug) + && !isNativeAliasCatalogEntry(entry) + && entry.owned_by !== COMBO_NAMESPACE + ? slug + : null; +} + +/** Append missing supported native rows from trusted catalog sources only. */ +export function mergeCatalogModelsWithNativeRecovery( + primaryCatalogModels: readonly RawEntry[], + nativeRecoverySources: readonly (readonly RawEntry[])[], +): RawEntry[] { + const merged = [...primaryCatalogModels]; + const recoveredNativeSlugs = new Set(primaryCatalogModels.flatMap(entry => { + const slug = recoverableNativeSlug(entry); + return slug === null ? [] : [slug]; + })); + for (const source of nativeRecoverySources) { + for (const entry of source) { + const slug = recoverableNativeSlug(entry); + if (slug === null || recoveredNativeSlugs.has(slug)) continue; + merged.push(structuredClone(entry) as RawEntry); + recoveredNativeSlugs.add(slug); + } + } + return merged; +} + export interface ObservedCatalogMergePolicy { /** Required observed/fixed set; the core merge never consults ambient catalog state. */ readonly nativeBackfillSlugs: readonly string[]; @@ -535,6 +620,7 @@ export interface ObservedCatalogMergeInput { readonly hasPhysicalComboProvider: boolean; readonly includeNativeOpenAi: boolean; readonly accountBoundEntries: readonly RawEntry[]; + readonly suppressedBareNativeSlugs?: ReadonlySet; readonly policy: ObservedCatalogMergePolicy; } @@ -563,6 +649,7 @@ export function mergeCatalogEntriesFromObservedState({ hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, + suppressedBareNativeSlugs = new Set(), policy, }: ObservedCatalogMergeInput): RawEntry[] { // Raw catalog rows contain nested arrays/objects that normalization mutates. Detach every row at @@ -683,12 +770,11 @@ export function mergeCatalogEntriesFromObservedState({ if (rank.has(slug)) return rank.get(slug)!; return featured.length > 0 ? Math.max(base, featured.length + 100) : base; }; - const native = includeNativeOpenAi + const nativeSourceEntries = includeNativeOpenAi ? catalogModelsForMerge .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.owned_by !== COMBO_NAMESPACE - && !freshBareComboAliases.has(m.slug as string) && (policy.unsupportedNativeEntries === "preserve" || !isUnsupportedOpenAiNativeSlug(m.slug as string))) .map(m => { @@ -710,6 +796,10 @@ export function mergeCatalogEntriesFromObservedState({ return preserved; }) : []; + const native = nativeSourceEntries.filter(entry => + typeof entry.slug !== "string" + || (!freshBareComboAliases.has(entry.slug) && !suppressedBareNativeSlugs.has(entry.slug)) + ); // Backfill any native OpenAI slug that the on-disk catalog is missing (e.g. gpt-5.5), so a // routed provider exposing the same id can never delete the native OpenAI/Codex base row. @@ -717,7 +807,7 @@ export function mergeCatalogEntriesFromObservedState({ const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : [])); if (includeNativeOpenAi) { for (const slug of policy.nativeBackfillSlugs) { - if (nativeSlugs.has(slug)) continue; + if (nativeSlugs.has(slug) || freshBareComboAliases.has(slug) || suppressedBareNativeSlugs.has(slug)) continue; nativeSlugs.add(slug); const entry = deriveEntry( template ? JSON.parse(JSON.stringify(template)) : null, @@ -730,12 +820,12 @@ export function mergeCatalogEntriesFromObservedState({ } } - const nativeBySlug = new Map(native.flatMap(entry => + const nativeSourceBySlug = new Map([...nativeSourceEntries, ...native].flatMap(entry => typeof entry.slug === "string" ? [[entry.slug, entry] as const] : [] )); const alignedAccountBoundEntries = detachedAccountBoundEntries.map(entry => { const nativeSlug = trustedAccountBoundNativeCatalogSlug(entry); - const source = nativeSlug === undefined ? undefined : nativeBySlug.get(nativeSlug); + const source = nativeSlug === undefined ? undefined : nativeSourceBySlug.get(nativeSlug); if (!source) return entry; const aligned = JSON.parse(JSON.stringify(source)) as RawEntry; aligned.slug = entry.slug; @@ -751,12 +841,13 @@ export function mergeCatalogEntriesFromObservedState({ ); const existingRoutedEntries = catalogModelsForMerge.filter(m => typeof m.slug === "string" - && m.slug.includes("/") + && (m.slug.includes("/") || isNativeAliasCatalogEntry(m)) && trustedAccountBoundNativeCatalogSlug(m) === undefined ); const preservedRoutedEntries = existingRoutedEntries.filter(entry => { const slug = entry.slug as string; if (freshEquivalent(slug)) return false; + if (isNativeAliasCatalogEntry(entry)) return exactComboSlugs.has(slug); // 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; @@ -795,11 +886,15 @@ export function mergeCatalogEntriesFromObservedState({ finalRoutedEntries = finalRoutedEntries.filter(entry => { const slug = typeof entry.slug === "string" ? entry.slug : ""; const comboOwned = slug.startsWith(`${COMBO_NAMESPACE}/`) || entry.owned_by === COMBO_NAMESPACE; - return !comboOwned || freshSlugs.has(slug); + const retainedNativeAlias = isNativeAliasCatalogEntry(entry) && exactComboSlugs.has(slug); + return !comboOwned || freshSlugs.has(slug) || retainedNativeAlias; }); } finalRoutedEntries = finalRoutedEntries.filter(entry => { - return !isExactComboCatalogEntry(entry, exactComboSlugs) + const slug = typeof entry.slug === "string" ? entry.slug : ""; + const retainedNativeAlias = isNativeAliasCatalogEntry(entry) && exactComboSlugs.has(slug); + return retainedNativeAlias + || !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 @@ -831,7 +926,7 @@ export function mergeCatalogEntriesFromObservedState({ const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries]; const mergedEntries = [...native, ...managedEntries].map(m => { const normalized = normalizeServiceTiers(m); - applyNativeOpenAiContextOverride(normalized); + if (!isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized); const exactCombo = isExactComboCatalogEntry(m, exactComboSlugs); const e = ensureStrictCatalogFields(normalized, { preserveExactInputModalities: exactCombo, @@ -896,6 +991,11 @@ export function mergeCatalogEntriesForSync( includeNativeOpenAi = true, accountBoundEntries: readonly RawEntry[] = [], legacyCustomModelSlugs: ReadonlySet = new Set(), + suppressedBareNativeSlugs: ReadonlySet = new Set( + routedEntries.flatMap(entry => ( + isNativeAliasCatalogEntry(entry) && typeof entry.slug === "string" ? [entry.slug] : [] + )), + ), ): 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. @@ -929,6 +1029,7 @@ export function mergeCatalogEntriesForSync( hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, + suppressedBareNativeSlugs, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "emit", @@ -1084,6 +1185,22 @@ function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null { : `${JSON.stringify(read.catalog, null, 2)}\n`; } +function catalogModelsForMergeWithNativeRecovery( + catalogPath: string, + catalog: RawCatalog, + onDiskCatalog: RawCatalog | null, +): RawEntry[] { + const primaryCatalogModels = onDiskCatalog?.models ?? catalog.models ?? []; + // Native-alias compatibility can omit disabled native rows from the effective catalog because + // Desktop's remote allowlist ignores `visibility: "hide"`. Keep current/pristine native recovery + // sources beside the on-disk rows so re-enabling a model restores its real metadata. Routed and + // user-authored rows still come only from the on-disk catalog. + return mergeCatalogModelsWithNativeRecovery(primaryCatalogModels, [ + catalog.models ?? [], + readCatalogBackup(catalogPath)?.models ?? [], + ]); +} + function writeRetainedCatalogSync({ config, goModels, @@ -1094,7 +1211,11 @@ function writeRetainedCatalogSync({ owningCodexHome, }: RetainedCatalogSyncWrite): RetainedCatalogSyncResult { const { catalogPath, catalog, onDiskCatalog } = read; - const catalogModelsForMerge = onDiskCatalog?.models ?? catalog.models ?? []; + const catalogModelsForMerge = catalogModelsForMergeWithNativeRecovery( + catalogPath, + catalog, + onDiskCatalog, + ); const template = findNativeTemplate(catalog); try { @@ -1122,6 +1243,7 @@ function writeRetainedCatalogSync({ const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; const exactComboSlugs = exactComboCatalogSlugs(config); + const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config); const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); @@ -1139,6 +1261,8 @@ function writeRetainedCatalogSync({ multiAgentMode, exactComboSlugs, accountSelectors, + suppressedBareNativeSlugs, + disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled, }); // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append @@ -1181,6 +1305,8 @@ function writeRetainedCatalogSync({ multiAgentMode, exactComboSlugs, accountSelectors, + suppressedBareNativeSlugs, + disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))), multiAgentV2Enabled, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) : []; @@ -1203,6 +1329,7 @@ function writeRetainedCatalogSync({ hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, + suppressedBareNativeSlugs, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "emit", diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index d5710f9258..12edf3285d 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -11,8 +11,8 @@ import { type CatalogSourceForGather, bundledCatalogCacheState, resolveCatalogSourceForGather, -} from "./catalog/bundled"; -import { + } from "./catalog/bundled"; + import { acceptCatalogGatherSourcePath, captureAndSealCatalogHomeSelection, captureCatalogGatherTargetIdentity, @@ -20,32 +20,37 @@ import { readCatalogGatherSource, sealCatalogGatherEvidenceSession, type CatalogFilesystemEvidenceSession, -} from "./catalog/filesystem-evidence"; -import { + } from "./catalog/filesystem-evidence"; + import { CatalogGatherBusyError, createCatalogGatherAuthorityIdentity, filterCatalogVisibleModels, gatherRoutedModelsForCatalogGather, type CatalogGatherProviderAuthOutcome, type CatalogGatherProviderModelOutcome, -} from "./catalog/provider-fetch"; -import { + } from "./catalog/provider-fetch"; + import { catalogBackupPathFor, catalogHasRoutedEntries, findNativeTemplate, legacyCatalogBackupPath, parseCatalogJson, type RawCatalog, -} from "./catalog/parsing"; -import { + type RawEntry, + } from "./catalog/parsing"; + import { buildCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, mergeCatalogEntriesFromObservedState, + mergeCatalogModelsWithNativeRecovery, orderForSubagents, -} from "./catalog/sync"; -import { multiAgentV2EnabledFromConfigText } from "./features"; -import { exactComboCatalogSlugs } from "./catalog/aggregation"; -import { + } from "./catalog/sync"; + import { multiAgentV2EnabledFromConfigText } from "./features"; + import { exactComboCatalogSlugs } from "./catalog/aggregation"; + import { + isNativeAliasCatalogEntry, + disabledNativeSlugs, + desktopAllowlistSuppressedNativeSlugs, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, @@ -151,7 +156,8 @@ interface ReadonlyRawCatalogLike { } function hasRoutedEntries(catalog: ReadonlyRawCatalogLike): boolean { - return (catalog.models ?? []).some(entry => typeof entry.slug === "string" && entry.slug.includes("/")); + return (catalog.models ?? []).some(entry => typeof entry.slug === "string" + && (entry.slug.includes("/") || isNativeAliasCatalogEntry(entry as RawEntry))); } function processEvidence(source: CatalogSourceForGather): CatalogProcessLocalEvidence { @@ -209,6 +215,7 @@ function prepareCatalog( baseline: ReadonlyMap, baselineCatalogModels: readonly Readonly>[], degradedProviderNames: ReadonlySet, + nativeRecoverySources: readonly (readonly RawEntry[])[] = [], ): RawCatalog { const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog; const template = findNativeTemplate(catalog); @@ -218,13 +225,19 @@ function prepareCatalog( const multiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; const exactComboSlugs = exactComboCatalogSlugs(config); + const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config); const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); const enabledProviders = Object.entries(config.providers).filter(([, provider]) => provider.disabled !== true); const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); const accountSelectors = shouldIncludeAccountBoundNativeOpenAi(config) ? visibleCodexAccountSelectors(config) : []; - const catalogModels = active?.models ?? catalog.models ?? []; + const disabledNative = disabledNativeSlugs(config); + const nativeCatalogModels = mergeCatalogModelsWithNativeRecovery( + active?.models ?? catalog.models ?? [], + [catalog.models ?? [], ...nativeRecoverySources], + ); + const catalogModels = nativeCatalogModels; const routedEntries = buildCatalogEntriesFromObservedState({ template: template ? JSON.parse(JSON.stringify(template)) : null, gptSlugs: [], @@ -234,6 +247,8 @@ function prepareCatalog( multiAgentMode, exactComboSlugs, accountSelectors, + suppressedBareNativeSlugs, + disabledNativeAccountSlugs: new Set(), multiAgentV2Enabled, }); const accountBoundEntries = accountSelectors.length === 0 @@ -247,6 +262,8 @@ function prepareCatalog( multiAgentMode, exactComboSlugs, accountSelectors, + suppressedBareNativeSlugs, + disabledNativeAccountSlugs: new Set([...disabledNative].filter(slug => suppressedBareNativeSlugs.has(slug))), multiAgentV2Enabled, }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined); const gatheredProviderNames = new Set(enabledProviders.map(([name]) => name)); @@ -276,6 +293,7 @@ function prepareCatalog( hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, + suppressedBareNativeSlugs, policy: { ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, warningPolicy: "suppress", @@ -362,6 +380,10 @@ export async function gatherCodexCatalogCandidate( new Set(providerModelOutcomes .filter(outcome => outcome.state === "degraded") .map(outcome => outcome.provider)), + [ + catalogFrom(keyedBackupBytes)?.models ?? [], + catalogFrom(legacyBackupBytes)?.models ?? [], + ], ); const preparedCatalogBytes = catalogBytes(preparedCatalog); const preparedCacheBytes = `${JSON.stringify({ diff --git a/src/combos/index.ts b/src/combos/index.ts index f1a0e8ebd0..6041427a92 100644 --- a/src/combos/index.ts +++ b/src/combos/index.ts @@ -4,9 +4,12 @@ export { comboConfigError, comboConfigIssues, comboDefaultEffort, + comboDisabledModelId, + comboDisabledModelSelectors, comboModelId, comboPublicModelId, getCombo, + isNativeAliasCombo, isValidComboId, listComboIds, normalizeComboConfig, diff --git a/src/combos/types.ts b/src/combos/types.ts index c5dcd51b63..c82c861a02 100644 --- a/src/combos/types.ts +++ b/src/combos/types.ts @@ -1,4 +1,5 @@ import { isCodexReasoningEffort } from "../reasoning-effort"; +import { SUPPORTED_NATIVE_OPENAI_SLUGS } from "../codex/catalog/native-models"; import type { OcxComboConfig, OcxComboDefaultEffort, @@ -24,11 +25,7 @@ const COMBO_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; * `combo/` prefix. Codex-facing slugs tolerate at most one "/", so deeper paths reject. */ const COMBO_ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,63})?$/; -/** - * Bare aliases in the OpenAI native family (gpt-*, o1-*, o3-*, o4-*, codex-*) are - * rejected: they collide with native catalog rows and the canonical-OpenAI routing - * branch, which cannot be shadowed honestly. - */ +/** Bare aliases in this family require the explicit `nativeAlias` opt-in below. */ const NATIVE_OPENAI_FAMILY_PATTERN = /^(?:gpt-|o1-|o3-|o4-|codex-)/; export interface ComboValidationIssue { @@ -42,9 +39,22 @@ export interface NormalizedComboConfig { defaultEffort: OcxComboDefaultEffort | null; /** Trimmed public alias, or null when the combo keeps the default `combo/` slug. */ alias: string | null; + /** Explicit native-family alias opt-in. */ + nativeAlias: boolean; + /** Display-only label for the catalog row, or null when unset. */ + displayName: string | null; targets: Array>; } +/** True only for an explicitly opted-in bare native-family alias. */ +export function isNativeAliasCombo( + combo: { alias?: string | null; nativeAlias?: boolean }, +): boolean { + const alias = typeof combo.alias === "string" ? combo.alias.trim() : ""; + return combo.nativeAlias === true + && SUPPORTED_NATIVE_OPENAI_SLUGS.has(alias); +} + export function targetKey(target: Pick): string { return `${target.provider}/${target.model}`; } @@ -66,6 +76,27 @@ export function comboPublicModelId(id: string, combo: { alias?: string | null }) return alias || comboModelId(id); } +/** + * Persisted selector that hides a combo from discovery. Native aliases keep the canonical + * `combo/` selector because their bare public id remains the native OpenAI disable key. + */ +export function comboDisabledModelId( + id: string, + combo: { alias?: string | null; nativeAlias?: boolean }, +): string { + return isNativeAliasCombo(combo) ? comboModelId(id) : comboPublicModelId(id, combo); +} + +/** Every persisted selector that can refer to this combo in `disabledModels`. */ +export function comboDisabledModelSelectors( + id: string, + combo: { alias?: string | null; nativeAlias?: boolean }, +): string[] { + const canonical = comboModelId(id); + const preferred = comboDisabledModelId(id, combo); + return preferred === canonical ? [canonical] : [canonical, preferred]; +} + /** * Resolve a client-requested model id to a combo config key. The canonical `combo/` * form wins first (back-compat); otherwise an exact alias match across configured combos. @@ -94,7 +125,7 @@ export function comboAliasIssues( id: string, alias: string, combos: Record | undefined, - options: { excludeComboId?: string } = {}, + options: { excludeComboId?: string; allowNativeAlias?: boolean } = {}, ): ComboValidationIssue[] { const issues: ComboValidationIssue[] = []; if (!COMBO_ALIAS_PATTERN.test(alias)) { @@ -110,10 +141,12 @@ export function comboAliasIssues( message: `alias must not use the reserved "${COMBO_NAMESPACE}/" namespace`, }); } - if (!alias.includes("/") && NATIVE_OPENAI_FAMILY_PATTERN.test(alias)) { + if (!alias.includes("/") + && NATIVE_OPENAI_FAMILY_PATTERN.test(alias) + && options.allowNativeAlias !== true) { issues.push({ path: ["alias"], - message: "bare aliases in the OpenAI native family (gpt-*, o1-*, o3-*, o4-*, codex-*) are not allowed", + message: "bare aliases in the OpenAI native family require nativeAlias=true", }); } for (const [otherId, other] of Object.entries(combos ?? {})) { @@ -193,9 +226,39 @@ export function comboConfigIssues( issues.push({ path: ["alias"], message: "alias must be a string" }); } else { const alias = body.alias.trim(); - if (alias) issues.push(...comboAliasIssues(id, alias, options.combos, options)); + if (alias) { + issues.push(...comboAliasIssues(id, alias, options.combos, { + ...options, + allowNativeAlias: body.nativeAlias === true, + })); + } + } + } + + if (body.nativeAlias !== undefined && typeof body.nativeAlias !== "boolean") { + issues.push({ path: ["nativeAlias"], message: "nativeAlias must be a boolean" }); + } + if (body.displayName !== undefined) { + if (typeof body.displayName !== "string") { + issues.push({ path: ["displayName"], message: "displayName must be a string" }); + } else if (body.displayName.trim().length > 128 || /[\u0000-\u001f\u007f]/.test(body.displayName)) { + issues.push({ + path: ["displayName"], + message: "displayName must be at most 128 characters and contain no control characters", + }); } } + const alias = typeof body.alias === "string" ? body.alias.trim() : ""; + const nativeAlias = body.nativeAlias === true; + if (nativeAlias && !SUPPORTED_NATIVE_OPENAI_SLUGS.has(alias)) { + issues.push({ + path: ["nativeAlias"], + message: "nativeAlias requires a currently supported bare OpenAI-native model alias", + }); + } + if (nativeAlias && (typeof body.displayName !== "string" || body.displayName.trim().length === 0)) { + issues.push({ path: ["displayName"], message: "displayName is required for native aliases" }); + } if (!Array.isArray(body.targets) || body.targets.length === 0) { issues.push({ path: ["targets"], message: "targets must be a non-empty array" }); @@ -271,11 +334,14 @@ export function comboConfigError( export function normalizeComboConfig(raw: OcxComboConfig): NormalizedComboConfig { const alias = typeof raw.alias === "string" ? raw.alias.trim() : ""; + const displayName = typeof raw.displayName === "string" ? raw.displayName.trim() : ""; return { strategy: raw.strategy ?? "failover", stickyLimit: raw.stickyLimit ?? 1, defaultEffort: raw.defaultEffort ?? null, alias: alias || null, + nativeAlias: raw.nativeAlias === true, + displayName: displayName || null, targets: raw.targets.map(target => ({ provider: target.provider.trim(), model: target.model.trim(), diff --git a/src/server/index.ts b/src/server/index.ts index abe38b99a2..3a6f12ebd5 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -815,12 +815,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server 0 ? NATIVE_OPENAI_MODELS : nativeSlugs; - const entries = buildCatalogEntries(loadCatalogTemplate(), catalogNativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2", exactComboCatalogSlugs(config), accountSelectors); + const entries = buildCatalogEntries(loadCatalogTemplate(), catalogNativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2", exactComboCatalogSlugs(config), accountSelectors, suppressedBareNativeSlugs); return jsonResponse({ models: applyNativeVisibility( entries, @@ -925,7 +927,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server !disabledNatives.has(slug)) : []; const visibleNatives = includeNativeOpenAi - ? accountSelectors.length > 0 ? selectorNativeSlugs : visibleNativeSlugs(config) + ? accountSelectors.length > 0 + ? selectorNativeSlugs.filter(slug => !shadowedNativeSlugs.has(slug)) + : visibleNativeSlugs(config) : []; const visibleAccountNatives = accountSelectors.flatMap(selector => selectorNativeSlugs.flatMap(metadataId => { diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index 81b25d7c8e..9c197d0d74 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -111,6 +111,8 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise rest)(normalized) - : normalized; + const { + alias: normalizedAlias, + nativeAlias: normalizedNativeAlias, + displayName: normalizedDisplayName, + ...normalizedBase + } = normalized; + const stored: import("../../types").OcxComboConfig = { + ...normalizedBase, + ...(normalizedAlias ? { alias: normalizedAlias } : {}), + ...(normalizedNativeAlias ? { nativeAlias: true } : {}), + ...(normalizedDisplayName ? { displayName: normalizedDisplayName } : {}), + }; const sourceId = renameFrom ?? id; const previous = config.combos?.[sourceId]; const oldPublicModel = previous ? comboPublicModelId(sourceId, previous) : null; const newPublicModel = comboPublicModelId(id, normalized); + const disabledIdentityChanged = previous !== undefined && ( + renameFrom !== undefined + || oldPublicModel !== newPublicModel + || (previous.nativeAlias === true) !== normalized.nativeAlias + ); + const oldDisabledSelectors = disabledIdentityChanged + ? new Set(comboDisabledModelSelectors(sourceId, previous)) + : new Set(); + const newDisabledModel = comboDisabledModelId(id, normalized); if (codexAccountNamespaceForModel(config.codexAccountNamespaces, newPublicModel)) { return jsonResponse({ error: CODEX_ACCOUNT_NAMESPACE_COMBO_ALIAS_COLLISION_ERROR }, 409); } @@ -137,36 +157,35 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise(); - if (oldPublicModel && oldPublicModel !== newPublicModel) { - migratedModels.add(oldPublicModel); + const migratedModels = new Map(); + if (oldPublicModel && oldPublicModel !== newPublicModel && previous?.nativeAlias !== true) { + migratedModels.set(oldPublicModel, newPublicModel); } - if (renameFrom) migratedModels.add(comboModelId(renameFrom)); - if (migratedModels.size > 0) { - const migrateReference = (model: string): string => ( - migratedModels.has(model) ? newPublicModel : model + if (renameFrom) { + // A bare native id is ambiguous after the alias changes. Preserve it as a native route, + // while the unambiguous canonical combo reference follows the renamed combo. + migratedModels.set( + comboModelId(renameFrom), + previous?.nativeAlias === true ? comboModelId(id) : newPublicModel, ); + } + if (migratedModels.size > 0) { + const migrateReference = (model: string): string => migratedModels.get(model) ?? model; const migrateAgentReference = (model: string): string => { const migrated = migrateReference(model); if (migrated !== model) shouldSyncClaudeAgentDefs = true; return migrated; }; - const migrateReferences = (models: string[]): string[] => [ - ...new Set(models.map(migrateReference)), - ]; - if (config.disabledModels) { - config.disabledModels = migrateReferences(config.disabledModels); - } if (config.subagentModels) { config.subagentModels = [...new Set(config.subagentModels.map(migrateAgentReference))]; } if (config.injectionModel && migratedModels.has(config.injectionModel)) { - config.injectionModel = newPublicModel; + config.injectionModel = migrateReference(config.injectionModel); } if (config.shadowCallIntercept?.model && migratedModels.has(config.shadowCallIntercept.model)) { config.shadowCallIntercept = { ...config.shadowCallIntercept, - model: newPublicModel, + model: migrateReference(config.shadowCallIntercept.model), }; } if (config.claudeCode) { @@ -187,6 +206,11 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise 0 && config.disabledModels) { + config.disabledModels = [...new Set(config.disabledModels.map(model => ( + oldDisabledSelectors.has(model) ? newDisabledModel : model + )))]; + } saveConfigPreservingClaudeCode(config); reconcileLiveStateStores(); clearComboSelectionState(id); @@ -197,7 +221,7 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise [ - comboModelId(id), - comboPublicModelId(id, combo), - ]), + Object.entries(config.combos ?? {}).flatMap(([id, combo]) => ( + comboDisabledModelSelectors(id, combo) + )), ); const targetComboSelectors = new Map>(); if (isVirtualComboNamespace) { for (const target of targets) { const combo = config.combos && Object.hasOwn(config.combos, target.id) ? config.combos[target.id] : undefined; if (!combo) return jsonResponse({ error: "invalid model visibility target" }, 400); - targetComboSelectors.set(target.id, new Set([comboModelId(target.id), comboPublicModelId(target.id, combo)])); + targetComboSelectors.set(target.id, new Set(comboDisabledModelSelectors(target.id, combo))); } } const matchesTarget = (stored: string, target: { id: string; native: boolean }) => target.native @@ -284,8 +283,12 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise(); + const nativeAliasSlugs = provider === "openai" + ? configuredNativeAliasSlugs(config) + : new Set(); disabled = disabled.filter(stored => ( knownComboSelectors.has(stored) + || nativeAliasSlugs.has(stored) || (!stored.startsWith(`${provider}/`) && !nativeIds.has(stored)) )); } diff --git a/src/server/management/model-rows.ts b/src/server/management/model-rows.ts index d493762d61..7cfa482b5a 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -79,11 +79,12 @@ export async function listManagementModelRows(config: OcxConfig): Promise ( - stored === namespaced || slugEquals(stored, m.provider, m.id) + (!nativeAlias && stored === namespaced) || slugEquals(stored, m.provider, m.id) )), ...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}), }; diff --git a/src/types.ts b/src/types.ts index ed47ec30af..3f89f59ee4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -905,6 +905,13 @@ export interface OcxComboConfig { * mandated model id; exact-match requests route here before any provider resolution. */ alias?: string; + /** + * Explicitly allow a bare OpenAI-native alias (for example `gpt-5.6-sol`) to + * be represented by this routed combo. Never inferred from `alias`. + */ + nativeAlias?: boolean; + /** Display-only label for the public catalog row. Required for native aliases. */ + displayName?: string; } export type OcxRoutingUnknownEvidenceMode = "allow" | "penalize" | "exclude"; diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 122977fa82..d35f292728 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -32,7 +32,15 @@ custom catalog remains the native metadata/template authority even when a bundle 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. +The app-server's model list comes from this shared catalog, not from patching the App. Codex Desktop +may still apply its remote native-only allowlist after `model/list`; an explicitly configured combo +`nativeAlias` is the bounded compatibility path. It replaces one supported bare native row with a +routed, labeled row, routes the bare id before canonical OpenAI, and keeps account-qualified native +selectors genuine. Missing target discovery capabilities inherit the replaced native row's metadata, +while explicit target limits remain authoritative. Because the affected renderer ignores `visibility: "hide"`, the presence of any +native alias also omits disabled bare native rows from the effective catalog. Dashboard rows remain +derived from the static native set, and sync retains bundled/pristine native recovery sources so a +later re-enable or alias removal restores native metadata. Provider live-model lists are cached with a configured TTL (`src/codex/model-cache.ts`). Adding, deleting, or editing a provider's shape clears that per-provider cache; a disabled-only change diff --git a/tests/cli-headless-parity.test.ts b/tests/cli-headless-parity.test.ts index 8a9b7ef229..38258abf7a 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -259,6 +259,28 @@ describe("headless GUI parity CLI", () => { }); }); + test("combo set forwards the explicit native-alias compatibility contract", async () => { + const runtime = fakeRuntime(); + const code = await handleComboCommand([ + "set", "nova-sol", + "--targets", "Nova1/codex/gpt-5.6-sol", + "--alias", "gpt-5.6-sol", + "--native-alias", + "--display-name", "Nova1 - codex-gpt-5.6-sol", + "--json", + ], runtime.deps); + expect(code).toBe(0); + expect(runtime.requests[0]?.body).toMatchObject({ + id: "nova-sol", + combo: { + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - codex-gpt-5.6-sol", + targets: [{ provider: "Nova1", model: "codex/gpt-5.6-sol" }], + }, + }); + }); + test("agent effort and roster use the same live mutation routes as GUI", async () => { const runtime = fakeRuntime(); expect(await handleAgentCommand(["effort", "set", "--main", "high", "--subagent", "medium", "--json"], runtime.deps)).toBe(0); diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index 060254d0e5..4a1bf62a90 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -138,6 +138,79 @@ describe("Codex catalog sync hardening", () => { expect(slugs).not.toContain("codex-auto-review"); // legacy dropped }); + test("native-alias suppression preserves authoritative metadata on account-qualified 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.6-sol", 0), + display_name: "Original Sol", + comp_hash: "native-sol-hash", + base_instructions: "Native Sol instructions", + model_messages: { instructions_template: "Native Sol instructions" }, + tool_mode: "code_mode_only", + }, + ], + }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + const { syncCatalogModels } = require("./src/codex/catalog"); + const config = { + port: 10100, + defaultProvider: "Nova1", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + liveModels: false + }, + Nova1: { + adapter: "openai-chat", + baseUrl: "https://api.example.test/v1", + liveModels: false, + models: ["codex/gpt-5.6-sol"] + } + }, + codexAccounts: [{ id: "stored-team-account", isMain: false }], + codexAccountNamespaces: { team: "stored-team-account" }, + combos: { + "nova-sol": { + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova Sol", + targets: [{ provider: "Nova1", model: "codex/gpt-5.6-sol" }] + } + } + }; + syncCatalogModels(config).then(res => console.log(JSON.stringify(res))); + `); + expect(r.status).toBe(0); + + const rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ + slug: string; + display_name?: string; + comp_hash?: string; + base_instructions?: string; + model_messages?: { instructions_template?: string }; + tool_mode?: string | null; + opencodex_catalog_kind?: string; + }>; + expect(rows.filter(row => row.slug === "gpt-5.6-sol")).toEqual([ + expect.objectContaining({ + display_name: "Nova Sol", + opencodex_catalog_kind: "combo-native-alias-v1", + }), + ]); + expect(rows.find(row => row.slug === "team/gpt-5.6-sol")).toMatchObject({ + comp_hash: "native-sol-hash", + base_instructions: "Native Sol instructions", + model_messages: { instructions_template: "Native Sol instructions" }, + tool_mode: "code_mode_only", + opencodex_catalog_kind: "account-selector-v1", + }); + }); + 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"); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index a468354634..72f9218322 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2,10 +2,11 @@ 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 { 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 { applyNativeVisibility, augmentRoutedModelsWithMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, CODEX_ACCOUNT_BOUND_CATALOG_KIND, CODEX_NATIVE_ALIAS_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, CODEX_PROVIDER_MODEL_CATALOG_KIND, + findNativeTemplate, } from "../src/codex/catalog/parsing"; import { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; import { @@ -66,6 +67,8 @@ function normalizedCombo( stickyLimit: 1, defaultEffort: "medium", alias: null, + nativeAlias: false, + displayName: null, targets: [ { provider: "a", model: "m1", weight: 1 }, { provider: "b", model: "m2", weight: 1 }, @@ -344,6 +347,60 @@ describe("combo catalog capability intersection", () => { expect(merged.map(entry => entry.slug)).not.toContain("vendor/stale"); }); + test("an empty routed gather preserves a configured native-alias row from disk", () => { + const alias = "gpt-5.6-sol"; + const exact = new Set([alias]); + const model = deriveComboCatalogModel( + "nova-sol", + normalizedCombo({ + alias, + nativeAlias: true, + displayName: "Nova1 - Sol", + }), + [memberA, memberB], + )!; + const existing = buildCatalogEntries( + nativeTemplate(), + [alias], + [model], + undefined, + false, + "default", + exact, + ).find(entry => entry.slug === alias)!; + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const merged = mergeCatalogEntriesForSync( + [existing], + [], + new Map(), + [], + false, + new Set(), + nativeTemplate(), + new Set(), + new Set(["a", "b"]), + "default", + exact, + false, + true, + [], + new Set(), + new Set([alias]), + ); + + expect(merged.filter(entry => entry.slug === alias)).toHaveLength(1); + expect(merged.find(entry => entry.slug === alias)).toMatchObject({ + display_name: "Nova1 - Sol", + owned_by: "combo", + opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND, + }); + expect(warning).not.toHaveBeenCalled(); + } finally { + warning.mockRestore(); + } + }); + test("preserves exact combo capabilities under an alias", () => { const alias = "deepseek-v4-flash"; const model = deriveComboCatalogModel( @@ -972,6 +1029,158 @@ describe("combo catalog capability intersection", () => { } }, 15_000); + test("native aliases use native capability fallbacks when discovery returns only an id", async () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "Nova1", + providers: { + Nova1: { + adapter: "openai-chat", + baseUrl: "https://nova.example/v1", + liveModels: false, + models: ["codex/gpt-5.6-sol", "codex/gpt-5.4-mini"], + }, + }, + combos: { + "nova-sol": { + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - codex-gpt-5.6-sol", + targets: [{ provider: "Nova1", model: "codex/gpt-5.6-sol" }], + }, + "nova-mini": { + alias: "gpt-5.4-mini", + nativeAlias: true, + displayName: "Nova1 - codex-gpt-5.4-mini", + targets: [{ provider: "Nova1", model: "codex/gpt-5.4-mini" }], + }, + }, + }; + + const rows = await gatherRoutedModels(config); + expect(rows.find(row => row.provider === "combo" && row.id === "nova-sol")).toMatchObject({ + alias: "gpt-5.6-sol", + nativeAlias: true, + contextWindow: 372_000, + maxInputTokens: 372_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"], + defaultReasoningEffort: "low", + }); + expect(rows.find(row => row.provider === "combo" && row.id === "nova-mini")).toMatchObject({ + alias: "gpt-5.4-mini", + nativeAlias: true, + contextWindow: 272_000, + maxInputTokens: 272_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium", "high", "xhigh"], + defaultReasoningEffort: "medium", + }); + }); + + test("a bare native disable does not starve a combo targeting that native model", async () => { + const alias = "gpt-5.6-sol"; + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + liveModels: false, + models: [], + }, + }, + disabledModels: [alias], + combos: { + native: { + alias, + nativeAlias: true, + displayName: "Native fallback", + targets: [{ provider: "openai", model: alias }], + }, + }, + }; + const rows = await gatherRoutedModels(config); + expect(rows.find(row => row.provider === "combo" && row.id === "native")).toMatchObject({ + alias, + nativeAlias: true, + }); + }); + + test("native aliases preserve explicit target capability limits", async () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "Nova1", + providerContextCaps: { Nova1: 350_000 }, + providers: { + Nova1: { + adapter: "openai-chat", + baseUrl: "https://nova.example/v1", + liveModels: false, + models: ["codex/gpt-5.6-sol"], + modelContextWindows: { "codex/gpt-5.6-sol": 128_000 }, + modelMaxInputTokens: { "codex/gpt-5.6-sol": 100_000 }, + modelInputModalities: { "codex/gpt-5.6-sol": ["text"] }, + modelReasoningEfforts: { "codex/gpt-5.6-sol": ["high"] }, + }, + }, + combos: { + "nova-sol": { + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - codex-gpt-5.6-sol", + targets: [{ provider: "Nova1", model: "codex/gpt-5.6-sol" }], + }, + }, + }; + + const rows = await gatherRoutedModels(config); + expect(rows.find(row => row.provider === "combo" && row.id === "nova-sol")).toMatchObject({ + contextWindow: 128_000, + contextCapped: false, + maxInputTokens: 100_000, + inputModalities: ["text"], + reasoningEfforts: ["high"], + }); + expect(rows.find(row => row.provider === "combo" && row.id === "nova-sol")) + .not.toHaveProperty("defaultReasoningEffort"); + }); + + test("native aliases apply provider caps to explicit target windows", async () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "Nova1", + providerContextCaps: { Nova1: 350_000 }, + providers: { + Nova1: { + adapter: "openai-chat", + baseUrl: "https://nova.example/v1", + liveModels: false, + models: ["codex/gpt-5.6-sol"], + modelContextWindows: { "codex/gpt-5.6-sol": 400_000 }, + }, + }, + combos: { + "nova-sol": { + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - codex-gpt-5.6-sol", + targets: [{ provider: "Nova1", model: "codex/gpt-5.6-sol" }], + }, + }, + }; + + const rows = await gatherRoutedModels(config); + expect(rows.find(row => row.provider === "combo" && row.id === "nova-sol")).toMatchObject({ + contextWindow: 350_000, + maxInputTokens: 350_000, + contextCapped: true, + }); + }); + test("exact combo slugs come only from current config", () => { expect(exactComboCatalogSlugs({ combos: { free: { targets: [{ provider: "a", model: "m1" }] }, @@ -1553,6 +1762,20 @@ function mergeObservedForTest( } describe("Codex catalog routed normalization", () => { + test("does not reuse a routed native alias as the native catalog template", () => { + const routedAlias = { + ...nativeTemplate(), + slug: "gpt-5.6-sol", + owned_by: "combo", + description: "Routed via opencodex → Nova1 (openai).", + opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND, + }; + const native = { ...nativeTemplate(), slug: "gpt-5.5", owned_by: "openai" }; + + expect(findNativeTemplate({ models: [routedAlias] })).toBeNull(); + expect(findNativeTemplate({ models: [routedAlias, native] })).toBe(native); + }); + 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; const rows = await gatherRoutedModels({ diff --git a/tests/codex-convergence-contract.test.ts b/tests/codex-convergence-contract.test.ts index 113bfd6c3d..383aaba85f 100644 --- a/tests/codex-convergence-contract.test.ts +++ b/tests/codex-convergence-contract.test.ts @@ -21,8 +21,9 @@ import { gatherCodexCatalogCandidate, type CodexCatalogCandidate, } from "../src/codex/convergence"; -import { resetCatalogRuntimeStateForTests } from "../src/codex/catalog"; +import { CODEX_NATIVE_ALIAS_CATALOG_KIND, resetCatalogRuntimeStateForTests } from "../src/codex/catalog"; import { + persistCodexRuntime, resetCodexRuntimeResolveCacheForTests, setCodexRuntimeResolveCacheForTests, } from "../src/codex/runtime"; @@ -222,6 +223,83 @@ test("catalog-only commit never creates the native pair or routing/history artif expect(manifest(root).join("\n")).not.toContain("history"); }); +test("management convergence restores omitted natives and retains a configured native alias", async () => { + const runtime = { command: "/tmp/codex", version: "0.146.0", source: "environment" as const }; + const bundled = JSON.parse(sourceCatalog("bundled")) as { models: Array> }; + bundled.models.push({ + ...bundled.models[0], + slug: "gpt-5.5", + display_name: "GPT-5.5", + }); + persistCodexRuntime(runtime, { configDir: opencodexHome, now: () => 0 }); + setCodexRuntimeResolveCacheForTests({ runtime, failures: [] }, { discoverAlternatives: false }); + setBundledCatalogCacheForTests(runtime, bundled, { opencodexHome }); + + const nativeAlias = { + ...bundled.models[0], + display_name: "Nova1 - Sol", + description: "Routed via opencodex → combo (combo).", + owned_by: "combo", + opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND, + input_modalities: ["text", "image"], + }; + writeFileSync(join(codexHome, "opencodex-catalog.json"), `${JSON.stringify({ + marker: "active-native-alias", + models: [nativeAlias], + }, null, 2)}\n`); + + const liveConfig: OcxConfig = { + port: 10100, + defaultProvider: "Nova1", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + liveModels: false, + models: [], + }, + Nova1: { + adapter: "openai-chat", + baseUrl: "https://nova.example/v1", + liveModels: false, + models: [], + }, + }, + combos: { + nova: { + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + targets: [{ provider: "Nova1", model: "codex/gpt-5.6-sol" }], + }, + }, + }; + saveConfig(liveConfig); + + const beforeGather = manifest(root); + const gathered = await gatherCodexCatalogCandidate(captureCatalogAdmissionSnapshot(liveConfig)); + expect(gathered.kind).toBe("candidate"); + expect(manifest(root)).toEqual(beforeGather); + if (gathered.kind !== "candidate") throw new Error(JSON.stringify(gathered)); + expect(await commitCodexCatalogCandidate(gathered.candidate, 1_000)).toMatchObject({ kind: "committed" }); + + const written = JSON.parse(readFileSync(join(codexHome, "opencodex-catalog.json"), "utf8")) as { + models: Array>; + }; + expect(written.models.find(entry => entry.slug === "gpt-5.5")).toMatchObject({ + display_name: "GPT-5.5", + }); + expect(written.models.filter(entry => entry.slug === "gpt-5.6-sol")).toEqual([ + expect.objectContaining({ + display_name: "Nova1 - Sol", + owned_by: "combo", + opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND, + }), + ]); +}); + test("the total lazy adapter preserves a persisted-success route when factory construction fails", async () => { const live = config(); const request = new ManagementRequest("http://localhost/api/disabled-models", { diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index fd4701b903..68d439a2a6 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -295,6 +295,67 @@ describe("combo management API", () => { }); }); + test("PUT round-trips an explicitly labeled native alias", async () => { + await withTempHome(async () => { + const config = baseConfig({ + combos: undefined, + disabledModels: ["gpt-5.6-sol"], + }); + saveConfig(config); + const response = await comboApi(config, "PUT", "/api/combos", { + id: "nova-sol", + combo: { + ...VALID_COMBO, + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - codex-gpt-5.6-sol", + }, + }); + + expect(response?.status).toBe(200); + expect(await responseJson(response)).toMatchObject({ + id: "nova-sol", + model: "gpt-5.6-sol", + combo: { + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - codex-gpt-5.6-sol", + }, + }); + expect(config.combos?.["nova-sol"]).toMatchObject({ + nativeAlias: true, + displayName: "Nova1 - codex-gpt-5.6-sol", + }); + const listed = await responseJson(await comboApi(config, "GET", "/api/combos")); + expect(listed.combos).toEqual([expect.objectContaining({ + id: "nova-sol", + nativeAlias: true, + displayName: "Nova1 - codex-gpt-5.6-sol", + })]); + }); + }); + + test("PUT rejects a native alias without a displayName without mutating config", async () => { + await withTempHome(async () => { + const config = baseConfig({ combos: undefined }); + saveConfig(config); + const beforeMemory = structuredClone(config); + const beforeDisk = readFileSync(getConfigPath(), "utf8"); + const response = await comboApi(config, "PUT", "/api/combos", { + id: "nova-sol", + combo: { + ...VALID_COMBO, + alias: "gpt-5.6-sol", + nativeAlias: true, + }, + }); + + expect(response?.status).toBe(400); + expect(config).toEqual(beforeMemory); + expect(readFileSync(getConfigPath(), "utf8")).toBe(beforeDisk); + }); + }); + test("PUT rejects aliases owned by a Codex account namespace without mutating config", async () => { await withTempHome(async () => { const config = baseConfig({ codexAccountNamespaces: { side: "side-account-id" } }); @@ -453,6 +514,65 @@ describe("combo management API", () => { }); }); + test("PUT rename preserves native disables and migrates only the canonical native-alias selector", async () => { + await withTempHome(async () => { + const config = baseConfig({ + disabledModels: ["gpt-5.6-sol", "combo/old", "gpt-5.5"], + subagentModels: ["gpt-5.6-sol", "combo/old"], + injectionModel: "gpt-5.6-sol", + shadowCallIntercept: { enabled: true, model: "gpt-5.6-sol" }, + claudeCode: { + model: "gpt-5.6-sol", + smallFastModel: "gpt-5.6-sol", + tierModels: { opus: "gpt-5.6-sol", sonnet: "combo/old" }, + modelMap: { native: "gpt-5.6-sol", combo: "combo/old" }, + }, + combos: { + old: { + ...VALID_COMBO, + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + }, + }, + }); + saveConfig(config); + + const response = await comboApi(config, "PUT", "/api/combos", { + id: "new", + renameFrom: "old", + combo: { + ...VALID_COMBO, + alias: "gpt-5.6-terra", + nativeAlias: true, + displayName: "Nova1 - Terra", + }, + }); + + expect(response?.status).toBe(200); + expect(config.disabledModels).toEqual(["gpt-5.6-sol", "combo/new", "gpt-5.5"]); + expect(config.subagentModels).toEqual(["gpt-5.6-sol", "combo/new"]); + expect(config.injectionModel).toBe("gpt-5.6-sol"); + expect(config.shadowCallIntercept?.model).toBe("gpt-5.6-sol"); + expect(config.claudeCode).toMatchObject({ + model: "gpt-5.6-sol", + smallFastModel: "gpt-5.6-sol", + tierModels: { opus: "gpt-5.6-sol", sonnet: "combo/new" }, + modelMap: { native: "gpt-5.6-sol", combo: "combo/new" }, + }); + expect(routeModel(config, "gpt-5.6-terra")).toMatchObject({ + providerName: "a", + modelId: "m1", + }); + expect(() => routeModel(config, "gpt-5.6-sol")).toThrow( + "requires the canonical openai provider", + ); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.disabledModels).toEqual(config.disabledModels); + expect(persisted.subagentModels).toEqual(config.subagentModels); + }); + }); + test("PUT rename rejects missing sources and existing destinations without mutation", async () => { await withTempHome(async () => { const config = baseConfig({ @@ -523,7 +643,7 @@ describe("combo management API", () => { free: { ...VALID_COMBO, alias: "deepseek-v4-flash" }, }, }); - config.providers.a!.liveModels = false; + for (const provider of Object.values(config.providers)) provider.liveModels = false; config.providers.a!.modelContextWindows = { m1: 128_000 }; const response = await comboApi(config, "GET", "/api/models"); @@ -581,6 +701,41 @@ describe("combo management API", () => { ]); }); + test("GET models exposes one native-alias row and uses only its canonical disable selector", async () => { + const config = baseConfig({ + disabledModels: ["gpt-5.6-sol"], + combos: { + free: { + ...VALID_COMBO, + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + }, + }, + }); + for (const provider of Object.values(config.providers)) provider.liveModels = false; + config.providers.a!.modelContextWindows = { m1: 128_000 }; + + const response = await comboApi(config, "GET", "/api/models"); + const rows = await response!.json() as Array<{ + provider: string; + id: string; + namespaced: string; + disabled: boolean; + native?: boolean; + }>; + expect(rows.filter(row => row.namespaced === "gpt-5.6-sol")).toEqual([ + expect.objectContaining({ provider: "combo", id: "free", disabled: false }), + ]); + + config.disabledModels!.push("combo/free"); + const disabledResponse = await comboApi(config, "GET", "/api/models"); + const disabledRows = await disabledResponse!.json() as typeof rows; + expect(disabledRows.filter(row => row.namespaced === "gpt-5.6-sol")).toEqual([ + expect.objectContaining({ provider: "combo", id: "free", disabled: true }), + ]); + }); + test("PUT clearing an alias deduplicates migrated references in stable order", async () => { await withTempHome(async () => { const config = baseConfig({ diff --git a/tests/combo-workspace-data.test.ts b/tests/combo-workspace-data.test.ts index 9ad3ed6544..c67fd4cac3 100644 --- a/tests/combo-workspace-data.test.ts +++ b/tests/combo-workspace-data.test.ts @@ -12,6 +12,7 @@ import { isValidComboId, parseComboList, toPutBody, + updateComboAliasDraft, validateComboDraft, } from "../gui/src/combo-workspace-data"; @@ -28,6 +29,8 @@ function combo(overrides: Partial = {}): ComboItem { id: "free", model: "combo/free", alias: null, + nativeAlias: false, + displayName: null, strategy: "failover", stickyLimit: 1, defaultEffort: "medium", @@ -87,6 +90,8 @@ describe("combo-workspace-data", () => { id: "fallback", model: "combo/fallback", alias: null, + nativeAlias: false, + displayName: null, strategy: "failover", stickyLimit: 1, defaultEffort: null, @@ -96,6 +101,8 @@ describe("combo-workspace-data", () => { id: "weighted", model: "combo/weighted", alias: null, + nativeAlias: false, + displayName: null, strategy: "round-robin", stickyLimit: 4, defaultEffort: "high", @@ -252,12 +259,67 @@ describe("combo-workspace-data", () => { expect(validate(combo({ alias: "combo" }))).toBe("aliasReservedNamespace"); expect(validate(combo({ alias: "gpt-5" }))).toBe("aliasNativeFamily"); expect(validate(combo({ alias: "codex-latest" }))).toBe("aliasNativeFamily"); + expect(validate(combo({ + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + }))).toBeNull(); + expect(validate(combo({ + alias: "ordinary-alias", + nativeAlias: true, + displayName: "Nova1 - Sol", + }))).toBe("unsupportedNativeAlias"); + expect(validate(combo({ + alias: "gpt-unknown", + nativeAlias: true, + displayName: "Nova1 - Unknown", + }))).toBe("unsupportedNativeAlias"); + expect(validate(combo({ + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: null, + }))).toBe("missingNativeAliasDisplayName"); + expect(validate(combo({ + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: `Nova1${String.fromCharCode(10)}Sol`, + }))).toBe("invalidDisplayName"); // Slashed ids in the same families are fine — only BARE names collide with natives. expect(validate(combo({ alias: "openai/gpt-5" }))).toBeNull(); expect(validate(combo({ alias: "taken" }), { existingAliases: ["taken"] })).toBe("duplicateAlias"); expect(validate(combo({ alias: "taken" }), { existingAliases: ["other"] })).toBeNull(); }); + test("alias edits can convert a hidden native alias back to an ordinary combo", () => { + const native = combo({ + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + }); + + const ordinary = updateComboAliasDraft(native, "fast-chat"); + expect(ordinary).toMatchObject({ + alias: "fast-chat", + model: "fast-chat", + nativeAlias: false, + displayName: null, + }); + expect(validate(ordinary)).toBeNull(); + expect(toPutBody(ordinary).combo).not.toHaveProperty("nativeAlias"); + expect(toPutBody(ordinary).combo).not.toHaveProperty("displayName"); + + expect(updateComboAliasDraft(native, "")).toMatchObject({ + alias: null, + model: "combo/free", + nativeAlias: false, + displayName: null, + }); + expect(updateComboAliasDraft(native, "gpt-5.6-terra")).toMatchObject({ + nativeAlias: true, + displayName: "Nova1 - Sol", + }); + }); + test("create drafts support bare, custom-prefixed, and default public names", () => { expect(comboPublicModelId("aka", "aka")).toBe("aka"); expect(comboPublicModelId("aka", "vendor/aka")).toBe("vendor/aka"); @@ -352,6 +414,29 @@ describe("combo-workspace-data", () => { expect("alias" in renamed.combo).toBe(false); }); + test("parse and PUT preserve advanced native-alias fields", () => { + const parsed = parseComboList({ + combos: [{ + id: "nova-sol", + model: "gpt-5.6-sol", + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + targets: [{ provider: "a", model: "m1" }], + }], + })[0]!; + + expect(parsed).toMatchObject({ + nativeAlias: true, + displayName: "Nova1 - Sol", + }); + expect(toPutBody(parsed).combo).toMatchObject({ + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + }); + }); + test("rejects duplicate targets", () => { expect(validate(combo({ targets: [ diff --git a/tests/combos.test.ts b/tests/combos.test.ts index b9b7d1ef35..e46f10a8d3 100644 --- a/tests/combos.test.ts +++ b/tests/combos.test.ts @@ -10,6 +10,8 @@ import { comboConfigError, comboConfigIssues, comboDefaultEffort, + comboDisabledModelId, + comboDisabledModelSelectors, comboFailureDecision, comboIdFromRawBody, comboModelId, @@ -170,6 +172,18 @@ describe("combo namespace primitives", () => { expect(targetKey({ provider: "a", model: "m1" })).toBe("a/m1"); }); + test("keeps native-alias discovery disables separate from the bare native key", () => { + const nativeAlias = { + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + }; + expect(comboDisabledModelId("nova-sol", nativeAlias)).toBe("combo/nova-sol"); + expect(comboDisabledModelSelectors("nova-sol", nativeAlias)).toEqual(["combo/nova-sol"]); + expect(comboDisabledModelSelectors("regular", { alias: "daily-fast" })) + .toEqual(["combo/regular", "daily-fast"]); + }); + test("resolves canonical ids before exact aliases and ignores unknown bare ids", () => { const config = baseConfig({ combos: { @@ -396,6 +410,41 @@ describe("deterministic combo selection", () => { }); }); + test("an explicitly configured native alias resolves before canonical OpenAI routing", () => { + const config = baseConfig({ + codexAccountNamespaces: { main: "@main" }, + combos: { + nova: { + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + targets: [{ provider: "a", model: "m1" }], + }, + }, + }); + config.providers.openai = { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + codexAccountMode: "direct", + }; + + expect(routeModel(config, "gpt-5.6-sol")).toMatchObject({ + providerName: "a", + modelId: "m1", + combo: { comboId: "nova", target: { provider: "a", model: "m1" } }, + }); + expect(routeModel(config, "combo/nova")).toMatchObject({ + providerName: "a", + modelId: "m1", + }); + const accountQualified = routeModel(config, "main/gpt-5.6-sol"); + expect(accountQualified).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.6-sol", + }); + expect(accountQualified.combo).toBeUndefined(); + }); + test("eligibility, exclusions, and state reset are deterministic", () => { const config = rrConfig(1, [1, 1]); expect(pickComboTarget(config, "free", { exclude: ["a/m1"] })?.target.provider).toBe("b"); @@ -431,12 +480,44 @@ describe("combo validation and normalization", () => { expect(comboAliasIssues("new", "vendor/model", combos)).toEqual([]); expect(comboAliasIssues("new", "combo/model", combos)[0]?.message).toContain("reserved"); expect(comboAliasIssues("new", "gpt-5", combos)[0]?.message).toContain("OpenAI native family"); + expect(comboAliasIssues("new", "gpt-5.6-sol", combos, { allowNativeAlias: true })).toEqual([]); expect(comboAliasIssues("new", "deepseek-v4-flash", combos)[0]?.message).toContain("already used"); expect(comboAliasIssues("renamed", "deepseek-v4-flash", combos, { excludeComboId: "free", })).toEqual([]); }); + test("requires an explicit labeled opt-in before a combo can own a native alias", () => { + const providers = baseConfig().providers; + expect(comboConfigError("nova", { + ...VALID_COMBO, + alias: "gpt-5.6-sol", + }, providers)).toContain("nativeAlias=true"); + expect(comboConfigError("nova", { + ...VALID_COMBO, + alias: "gpt-5.6-sol", + nativeAlias: true, + }, providers)).toContain("displayName is required"); + expect(comboConfigError("nova", { + ...VALID_COMBO, + alias: "deepseek-v4-flash", + nativeAlias: true, + displayName: "Not native", + }, providers)).toContain("requires a currently supported bare OpenAI-native"); + expect(comboConfigError("nova", { + ...VALID_COMBO, + alias: "gpt-future-preview", + nativeAlias: true, + displayName: "Future model", + }, providers)).toContain("requires a currently supported bare OpenAI-native"); + expect(comboConfigError("nova", { + ...VALID_COMBO, + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + }, providers)).toBeNull(); + }); + test("reports every validation row with a stable path and message", () => { const providers = baseConfig().providers; const cases: Array<{ @@ -509,6 +590,8 @@ describe("combo validation and normalization", () => { stickyLimit: 1, defaultEffort: "high", alias: null, + nativeAlias: false, + displayName: null, targets: [{ provider: "a", model: "m1", weight: 2 }], }); expect(normalizeComboConfig({ targets: [{ provider: "a", model: "m1" }] }).defaultEffort).toBeNull(); diff --git a/tests/model-visibility-management-api.test.ts b/tests/model-visibility-management-api.test.ts index d7ea73f42b..6e8e90a995 100644 --- a/tests/model-visibility-management-api.test.ts +++ b/tests/model-visibility-management-api.test.ts @@ -259,6 +259,49 @@ describe("atomic model visibility management", () => { expect(refreshes).toBe(4); }); + test("native-alias toggles preserve the separate bare native disable key", async () => { + const config = loadConfig(); + config.combos = { + nova: { + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + targets: [{ provider: "google-antigravity", model: "gemini-3.1-pro" }], + }, + }; + config.disabledModels = ["gpt-5.6-sol", "gpt-5.5", "combo/nova", "other/keep"]; + saveConfig(config); + + expect((await put({ + scope: "models", + provider: "combo", + targets: [{ id: "nova" }], + enabled: true, + })).status).toBe(200); + expect(loadConfig().disabledModels).toEqual(["gpt-5.6-sol", "gpt-5.5", "other/keep"]); + + expect((await put({ + scope: "models", + provider: "combo", + targets: [{ id: "nova" }], + enabled: false, + })).status).toBe(200); + expect(loadConfig().disabledModels).toEqual([ + "gpt-5.6-sol", "gpt-5.5", "other/keep", "combo/nova", + ]); + + const current = loadConfig(); + const nativeTargets = nativeModelRows(current).map(row => ({ id: row.slug, native: true })); + expect(nativeTargets.some(target => target.id === "gpt-5.6-sol")).toBe(false); + expect((await put({ + scope: "provider", + provider: "openai", + targets: nativeTargets, + enabled: true, + })).status).toBe(200); + expect(loadConfig().disabledModels).toEqual(["gpt-5.6-sol", "other/keep", "combo/nova"]); + }); + test("uses raw allowlist ids, canonical routed slugs, and rejects invalid requests", async () => { await put({ scope: "models", provider: "google-antigravity", targets: [{ id: "vendor/model" }, { id: "vendor/model" }], enabled: true }); expect(loadConfig().providers["google-antigravity"].selectedModels).toContain("vendor/model"); diff --git a/tests/native-alias-maintainer-regressions.test.ts b/tests/native-alias-maintainer-regressions.test.ts new file mode 100644 index 0000000000..f93edc500e --- /dev/null +++ b/tests/native-alias-maintainer-regressions.test.ts @@ -0,0 +1,149 @@ +import { expect, test } from "bun:test"; +import { + catalogHasRoutedEntries, + type CatalogModel, + type RawEntry, +} from "../src/codex/catalog/parsing"; +import { + buildCatalogEntries, + mergeCatalogEntriesForSync, + mergeCatalogModelsWithNativeRecovery, +} from "../src/codex/catalog/sync"; +import { CODEX_NATIVE_ALIAS_CATALOG_KIND } from "../src/codex/catalog/kinds"; + +function aliasModel(slug: string): CatalogModel { + return { + id: `alias-${slug}`, + provider: "combo", + alias: slug, + nativeAlias: true, + displayName: `Routed ${slug}`, + owned_by: "combo", + contextWindow: 128_000, + maxInputTokens: 100_000, + inputModalities: ["text", "image"], + reasoningEfforts: ["low", "medium"], + defaultReasoningEffort: "medium", + }; +} + +test("native-alias catalog rows are routed state, never pristine backup input", () => { + expect(catalogHasRoutedEntries({ + models: [{ + slug: "gpt-5.6-sol", + owned_by: "combo", + opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND, + }], + })).toBe(true); +}); + +test("native recovery copies nested reasoning metadata instead of mutating the source", () => { + const source: RawEntry = { + slug: "gpt-5.6-sol", + display_name: "GPT-5.6-Sol", + supported_reasoning_levels: [{ effort: "medium", description: "Medium" }], + service_tiers: [{ id: "priority", display_name: "Fast" }], + }; + const merged = mergeCatalogModelsWithNativeRecovery([], [[source]]); + const recovered = merged.find(row => row.slug === "gpt-5.6-sol"); + expect(recovered).toBeDefined(); + const levels = recovered!.supported_reasoning_levels as Array>; + levels.push({ effort: "ultra", description: "Ultra" }); + const tiers = recovered!.service_tiers as Array>; + tiers[0]!.display_name = "Changed"; + expect(source.supported_reasoning_levels).toEqual([{ effort: "medium", description: "Medium" }]); + expect(source.service_tiers).toEqual([{ id: "priority", display_name: "Fast" }]); + expect(recovered).not.toBe(source); + expect(recovered!.supported_reasoning_levels).not.toBe(source.supported_reasoning_levels); + expect(recovered!.service_tiers).not.toBe(source.service_tiers); +}); + +test("disabled native slugs do not leak through account-qualified clones", () => { + const slug = "gpt-5.5"; + const rows = buildCatalogEntries( + null, [slug], [], undefined, false, "default", new Set(), ["main"], + new Set([slug]), new Set([slug]), + ); + expect(rows.find(row => row.slug === slug)).toBeUndefined(); + expect(rows.find(row => row.slug === `main/${slug}`)).toBeUndefined(); +}); + +test("disabling a shadowed native row keeps its compatibility combo but removes the account clone", () => { + const slug = "gpt-5.6-sol"; + const rows = buildCatalogEntries( + null, [slug], [aliasModel(slug)], undefined, false, "default", new Set([slug]), ["main"], + new Set([slug]), new Set([slug]), + ); + expect(rows.find(row => row.slug === slug)?.opencodex_catalog_kind).toBe(CODEX_NATIVE_ALIAS_CATALOG_KIND); + expect(rows.find(row => row.slug === `main/${slug}`)).toBeUndefined(); +}); + +test("duplicate native aliases keep the first combo deterministically", () => { + const slug = "gpt-5.6-sol"; + const first = { ...aliasModel(slug), id: "first", displayName: "First" }; + const second = { ...aliasModel(slug), id: "second", displayName: "Second" }; + const rows = buildCatalogEntries(null, [slug], [first, second], undefined, false, "default", new Set([slug])); + expect(rows.filter(row => row.slug === slug)).toHaveLength(1); + expect(rows.find(row => row.slug === slug)?.display_name).toBe("First"); +}); + +test("transient preservation keeps a native alias without input modalities", () => { + const slug = "gpt-5.6-sol"; + const existing: RawEntry = { + slug, + display_name: "Nova Sol", + owned_by: "combo", + opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND, + visibility: "list", + }; + const rows = mergeCatalogEntriesForSync( + [existing], [], new Map(), [], false, new Set(), null, new Set(), new Set(), + "default", new Set([slug]), false, true, [], new Set([slug]), + ); + expect(rows.find(row => row.slug === slug)).toBeDefined(); +}); + +test("native aliases retain upstream multi-agent pins in default mode", () => { + for (const [slug, expected] of [["gpt-5.6-sol", "v2"], ["gpt-5.6-luna", "v1"]] as const) { + const rows = buildCatalogEntries( + null, + [slug], + [aliasModel(slug)], + undefined, + false, + "default", + new Set([slug]), + ); + expect(rows.find(row => row.slug === slug)?.multi_agent_version).toBe(expected); + } +}); + +test("a featured bare native alias does not feature its account-qualified OpenAI clone", () => { + const slug = "gpt-5.6-sol"; + const rows = buildCatalogEntries( + null, + [slug], + [aliasModel(slug)], + [slug], + false, + "default", + new Set([slug]), + ["main"], + ); + const routed = rows.find(row => row.slug === slug)!; + const account = rows.find(row => row.slug === `main/${slug}`)!; + expect(routed.priority).toBe(0); + expect(account.priority).not.toBe(0); + + const exactRows = buildCatalogEntries( + null, + [slug], + [aliasModel(slug)], + [`main/${slug}`], + false, + "default", + new Set([slug]), + ["main"], + ); + expect(exactRows.find(row => row.slug === `main/${slug}`)?.priority).toBe(0); +}); diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 5cdd682335..b294ba6f3f 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -5,6 +5,7 @@ import { applyNativeVisibility, buildCatalogEntries, CODEX_ACCOUNT_BOUND_CATALOG_KIND, + desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, @@ -65,6 +66,28 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(rows.find(r => r.slug === "gpt-5.6-sol")?.contextWindow).toBe(372_000); }); + test("native aliases suppress their native dashboard row and activate Desktop allowlist pruning", () => { + const config = makeConfig({ + disabledModels: ["gpt-5.6-sol", "gpt-5.5"], + combos: { + nova: { + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + targets: [{ provider: "nova", model: "codex/gpt-5.6-sol" }], + }, + }, + }); + const rows = nativeModelRows(config); + expect(rows.some(row => row.slug === "gpt-5.6-sol")).toBe(false); + expect(rows.find(row => row.slug === "gpt-5.5")?.disabled).toBe(true); + expect(desktopAllowlistSuppressedNativeSlugs(config)) + .toEqual(new Set(["gpt-5.6-sol", "gpt-5.5"])); + expect(desktopAllowlistSuppressedNativeSlugs(makeConfig({ + disabledModels: ["gpt-5.5"], + }))).toEqual(new Set()); + }); + test("configured public selectors replace bare picker rows with account-qualified native clones", () => { const template = nativeTemplate(); template.comp_hash = "native-compaction-hash"; diff --git a/tests/native-profile-drain-server.test.ts b/tests/native-profile-drain-server.test.ts index 17b3fe2714..2e96f20669 100644 --- a/tests/native-profile-drain-server.test.ts +++ b/tests/native-profile-drain-server.test.ts @@ -179,6 +179,10 @@ describe("native main profile scoped server admission", () => { close() { upstreamCloses += 1; }, }, }); + const waitUntil = async (condition: () => boolean): Promise => { + const deadline = Date.now() + 2_000; + while (!condition() && Date.now() < deadline) await Bun.sleep(10); + }; const liveProvider = (codexAccountMode: "direct" | "pool") => ({ adapter: "openai-responses" as const, baseUrl: "https://chatgpt.com/backend-api/codex", @@ -232,8 +236,7 @@ describe("native main profile scoped server admission", () => { }, { once: true })); ws.close(); await closed; - const deadline = Date.now() + 2_000; - while (getNativeMainProfileRequestCount() > 0 && Date.now() < deadline) await Bun.sleep(10); + await waitUntil(() => getNativeMainProfileRequestCount() === 0); return requestCountAtDownstreamClose; }; const handshakeStatus = (server: ReturnType, path: string) => new Promise((resolve, reject) => { @@ -281,14 +284,15 @@ describe("native main profile scoped server admission", () => { {} as OcxConfig, { manager, drainTimeoutMs: 0 }, ); - expect(blocked?.status).toBe(409); - expect(switches).toBe(0); - // The proxy must still own native-main at the downstream close boundary, - // then release only after the mock authenticated upstream closes. - expect(await closeSocket(client)).toBe(1); - client = undefined; - expect(upstreamCloses).toBe(1); - expect(getNativeMainProfileRequestCount()).toBe(0); + expect(blocked?.status).toBe(409); + expect(switches).toBe(0); + // The proxy must still own native-main at the downstream close boundary, + // then release only after the mock authenticated upstream closes. + expect(await closeSocket(client)).toBe(1); + client = undefined; + await waitUntil(() => upstreamCloses >= 1); + expect(upstreamCloses).toBe(1); + expect(getNativeMainProfileRequestCount()).toBe(0); const afterClose = await handleNativeProfileAPI( switchRequest(), new URL("http://localhost/api/native-main-profiles/switch"),