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 534f97e27a..856d2df811 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 @@ -41,6 +41,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 @@ -99,6 +114,9 @@ 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. +- 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. The dashboard keeps + their static switches and sync restores native metadata when a row is re-enabled. - Native rows come from the supported static set, so a disabled native model stays visible in the dashboard and can be turned back on. diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 040da7ec86..5db4863706 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,51 @@ 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. Account-qualified rows such as +`main/gpt-5.6-sol` remain genuine OpenAI routes. + +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 +259,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 +303,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 77a93e9fb4..6f3fa07bdb 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 は変わりません。ただし、`codexAccountNamespaces` に対象アカウントが存在する selector がある場合、opencodex は対応するアカウントごとに `/` 行を追加し、ピッカーでは bare native 行を非表示にします。Selector 名はユーザーが決める公開ラベルであり、組み込みのアカウント role の意味はありません。`selector` 付きの行を選択すると、対応付けられたアカウントだけが使用され、アクティブな Pool アカウントは変更されません。対象を利用できない場合、別のアカウントへ切り替えずにリクエストが失敗します。詳しくは [Codex アカウントの明示的な selector](/reference/configuration/routing/#exact-codex-account-selectors) を参照してください。API GPT-5.6 エントリは 1,050,000 コンテキスト / 922,000 最大入力を使用し、`*-pro` ピッカー ID は `reasoning.mode: "pro"` のベース ワイヤ モデルに解決されますが、ログ、使用状況、およびピッカー状態は仮想 ID を保持します。 API カタログは、`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、およびそれらの 3 つの Pro 仮想 ID の 8 つの ID に固定されています。汎用の `gpt-5.6-pro` エイリアスはありません。コンパクト リクエストは、選択された層を保持しますが、推論オブジェクトなしで基本モデルを送信します。 diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 178703000e..ccb62ef71c 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,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 が必要です。`create` および `update` は `set` の +エイリアス、`delete` は `remove` のエイリアスです。 ### 管理 API @@ -212,6 +215,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 f6c454c5ea..c63f81fac9 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 41c8c60b0d..525c94c8be 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,9 @@ 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이 필요합니다. `create`와 `update`는 `set`의 별칭이고 `delete`는 `remove`의 별칭입니다. ### Management API @@ -211,6 +214,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..398b66fc47 100644 --- a/docs-site/src/content/docs/reference/cli/agents.md +++ b/docs-site/src/content/docs/reference/cli/agents.md @@ -57,6 +57,11 @@ 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. Account-qualified and provider-qualified OpenAI routes remain distinct; +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..81e9f92213 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. Account-qualified and provider-qualified OpenAI routes remain distinct. | +| `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 1fad62d418..a406f2ac53 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 fad7a8dc49..1db80a66d3 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. | +| `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..298f54fd12 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. OpenAI-маршруты с квалификатором аккаунта или провайдера остаются отдельными. | +| `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 2e9eaad167..b01c3f0e94 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。但当 `codexAccountNamespaces` 中有目标账户存在的 selector 时,opencodex 会为映射账户添加独立的 `/` 行,并在选择器中隐藏裸原生行。Selector 名称是用户自定义的公开标签,没有内置的账户角色含义。选择带 `selector` 的行只会使用映射账户,不会更改当前 Pool 账户;目标不可用时,请求会直接失败,不会切换到其他账户。详情请参阅[精确 Codex 账户选择器](/reference/configuration/routing/#exact-codex-account-selectors)。API GPT-5.6 条目使用 1,050,000 context / 922,000 max input,而 `*-pro` 选择器 id 会解析到基础线协议模型,并在日志、用量和选择器状态中保留虚拟 id,同时带上 `reasoning.mode: "pro"`。API 目录固定为恰好八个 id:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna,以及它们三个 Pro 虚拟 id;不存在通用的 `gpt-5.6-pro` 别名。Compact 请求会保留所选 tier,但发送基础模型且不带 reasoning 对象。 @@ -24,6 +26,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/)。 @@ -67,6 +82,8 @@ visibility = "list" `disabledModels` 只会隐藏对应的 selector 行。 - 裸原生 GPT id 是裸 slug。禁用后会隐藏裸行以及该模型的所有 account-selector 克隆行, 同时保留目录条目以便之后重新启用。 +- 配置至少一个 native-alias combo 后,已禁用的裸原生行会直接从有效 catalog 中移除,因为受影响的 + Desktop 会忽略隐藏标记;dashboard 仍保留静态开关,重新启用时会恢复原生 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 0a73d5f3ea..198b8273ba 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..90396f9852 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,11 @@ 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;带账号或提供方限定的 OpenAI 路由仍是独立路由。启用这组 +兼容选项前,请先阅读 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 bc29bff703..e232b92ea8 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"; @@ -61,6 +65,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; @@ -97,6 +105,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; } @@ -150,6 +171,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), @@ -207,6 +230,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 @@ -227,6 +252,8 @@ export function toPutBody(item: ComboItem, options: { renameFrom?: string } = {} stickyLimit?: number; defaultEffort: ComboEffort | null; alias?: string; + nativeAlias?: true; + displayName?: string; }; } { return { @@ -240,6 +267,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() } : {}), }, }; } @@ -253,6 +282,9 @@ export type ComboDraftError = | "invalidAlias" | "aliasReservedNamespace" | "aliasNativeFamily" + | "unsupportedNativeAlias" + | "missingNativeAliasDisplayName" + | "invalidDisplayName" | "duplicateAlias" | "noTargets" | "incompleteTarget" @@ -282,12 +314,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) { @@ -323,6 +366,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 caaa05bed1..9d8c9f5fa1 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"; @@ -52,7 +53,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) { @@ -201,11 +202,7 @@ 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")} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index a4d633e10a..29c74d033e 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1725,6 +1725,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 df67c75023..1cecea882a 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1758,6 +1758,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 eb98a71eb6..0095656547 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1790,6 +1790,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 339cdbe970..41cd57dd14 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1752,6 +1752,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 7904e27c3e..20e0444044 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1832,6 +1832,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 8b16dbe6c8..48e350ac04 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1745,6 +1745,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/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 3ee398ecf3..b42fc6f658 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, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch"; diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index 03b0e17c96..253257bf0b 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, @@ -152,6 +153,8 @@ export function deriveComboCatalogModel( 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 +255,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 13fd33bbab..fe358a05fc 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 73a63bd637..3f419decc1 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,12 @@ 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; } 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 +159,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 +188,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 +208,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 41251cbbb1..6a39f9e279 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -33,6 +33,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES } 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"); @@ -90,6 +91,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 @@ -193,7 +196,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; } @@ -320,7 +328,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 nativeAlias = entry.opencodex_catalog_kind === CODEX_NATIVE_ALIAS_CATALOG_KIND; + const upstream = nativeAlias + ? undefined + : UPSTREAM_NATIVE_ENTRIES.get(trustedAccountBoundNativeCatalogSlug(entry) ?? slug); const upstreamPin = upstream?.multi_agent_version; if (typeof upstreamPin === "string") { entry.multi_agent_version = upstreamPin; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index b88220c156..ece30386e3 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 { 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"; @@ -1078,11 +1078,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); @@ -1182,6 +1185,43 @@ 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 fallbackContextWindow = applyProviderContextCap(nativeContextWindow, member.contextCap); + const contextWindow = typeof member.contextWindow === "number" && member.contextWindow > 0 + ? member.contextWindow + : fallbackContextWindow; + 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] } : {}), + ...(member.contextCap !== undefined && contextWindow !== undefined + ? { contextCapped: contextWindow < nativeContextWindow } + : {}), + }; + }); +} + async function gatherRoutedModelsUncached( config: OcxConfig, capture: GatherFlightCapture, @@ -1265,11 +1305,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 79113d42f4..1b2c2d79e7 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -31,7 +31,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json"; import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogBackupPathFor, catalogHasRoutedEntries, catalogModelSlug, ensureStrictCatalogFields, findNativeTemplate, isDefaultCatalogPath, isRoutedModelCompatibilityExcluded, legacyCatalogBackupPath, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing"; -import { applyNativeVisibility, disabledNativeSlugs, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; +import { applyNativeVisibility, CODEX_NATIVE_ALIAS_CATALOG_KIND, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, isNativeAliasCatalogEntry, isUnsupportedOpenAiNativeSlug, nativeOpenAiSlugs, NATIVE_OPENAI_MODELS, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, shouldUpgradeToUpstreamEntry, SUPPORTED_NATIVE_OPENAI_SLUGS, upstreamNativeEntry } from "./metadata"; import { bundledCatalogCacheState, loadBundledCodexCatalog, @@ -320,6 +320,7 @@ export function buildCatalogEntries( multiAgentMode: MultiAgentMode = "default", exactComboSlugs: ReadonlySet = new Set(), accountSelectors: readonly string[] = [], + suppressedBareNativeSlugs: ReadonlySet = new Set(), ): RawEntry[] { // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog @@ -330,14 +331,41 @@ export function buildCatalogEntries( const out: RawEntry[] = []; const nativeEntries: RawEntry[] = []; const collisionSkipped = resolveSlugAliasCollisions(goModels); + const emittedNativeAliases = new Set(); + const nativeAliasesBySlug = new Map(goModels.flatMap(model => ( + model.provider === COMBO_NAMESPACE + && model.nativeAlias === true + && typeof model.alias === "string" + && !model.alias.includes("/") + ? [[model.alias, model] as const] + : [] + ))); 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); } for (const [selectorIndex, selector] of accountSelectors.entries()) { for (const [nativeIndex, native] of nativeEntries.entries()) { @@ -359,7 +387,7 @@ export function buildCatalogEntries( } } 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); @@ -375,6 +403,9 @@ export function buildCatalogEntries( 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; @@ -436,11 +467,45 @@ 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 without importing routed or + * user-authored rows from those sources. Primary rows always retain their original order. + */ +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(entry); + recoveredNativeSlugs.add(slug); + } + } + return merged; +} + export function mergeCatalogEntriesForSync( catalogModels: RawEntry[], routedEntries: RawEntry[], @@ -460,6 +525,11 @@ export function mergeCatalogEntriesForSync( hasPhysicalComboProvider = false, includeNativeOpenAi = true, accountBoundEntries: readonly RawEntry[] = [], + suppressedBareNativeSlugs: ReadonlySet = new Set( + routedEntries.flatMap(entry => ( + isNativeAliasCatalogEntry(entry) && typeof entry.slug === "string" ? [entry.slug] : [] + )), + ), ): RawEntry[] { const rank = new Map(featured.map((slug, i) => [slug, i] as const)); const native = includeNativeOpenAi @@ -467,6 +537,7 @@ export function mergeCatalogEntriesForSync( .filter(m => typeof m.slug === "string" && !(m.slug as string).includes("/") && m.owned_by !== COMBO_NAMESPACE + && !suppressedBareNativeSlugs.has(m.slug as string) && !goIds.has(m.slug as string) && !isUnsupportedOpenAiNativeSlug(m.slug as string)) .map(m => { @@ -508,7 +579,7 @@ export function mergeCatalogEntriesForSync( const nativeSlugs = new Set(native.flatMap(m => typeof m.slug === "string" ? [m.slug] : [])); if (includeNativeOpenAi) { for (const slug of nativeOpenAiSlugs()) { - if (nativeSlugs.has(slug)) continue; + if (nativeSlugs.has(slug) || suppressedBareNativeSlugs.has(slug)) continue; nativeSlugs.add(slug); const priority = rank.has(slug) ? rank.get(slug)! @@ -541,7 +612,7 @@ export function mergeCatalogEntriesForSync( let finalRoutedEntries = routedEntries; const existingRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" - && m.slug.includes("/") + && (m.slug.includes("/") || isNativeAliasCatalogEntry(m)) && trustedAccountBoundNativeCatalogSlug(m) === undefined ); const preservingExistingRouted = routedEntries.length === 0 @@ -551,12 +622,15 @@ export function mergeCatalogEntriesForSync( // itself authored for a provider that is no longer configured are ghosts, // not protected foreign entries. finalRoutedEntries = existingRoutedEntries.filter(m => { + if (isNativeAliasCatalogEntry(m)) { + return typeof m.slug === "string" && exactComboSlugs.has(m.slug); + } const provider = (m.slug as string).slice(0, (m.slug as string).indexOf("/")); return !(isOcxAuthoredRoutedEntry(m) && !gatheredProviderNames.has(provider)); }); } else { const preservedForeignRouted = catalogModels.filter(m => { - if (typeof m.slug !== "string" || !m.slug.includes("/")) return false; + if (typeof m.slug !== "string" || (!m.slug.includes("/") && !isNativeAliasCatalogEntry(m))) return false; if (trustedAccountBoundNativeCatalogSlug(m) !== undefined) return false; const provider = m.slug.slice(0, m.slug.indexOf("/")); if (gatheredProviderNames.has(provider) || freshSlugs.has(m.slug)) return false; @@ -570,7 +644,10 @@ export function mergeCatalogEntriesForSync( 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 = preservingExistingRouted + && isNativeAliasCatalogEntry(entry) + && exactComboSlugs.has(slug); + return !comboOwned || freshSlugs.has(slug) || retainedNativeAlias; }); } finalRoutedEntries = finalRoutedEntries.filter(entry => { @@ -598,7 +675,7 @@ export function mergeCatalogEntriesForSync( const managedEntries = [...finalRoutedEntries, ...alignedAccountBoundEntries]; const mergedEntries = [...native, ...managedEntries].map(m => { const normalized = normalizeServiceTiers(m); - applyNativeOpenAiContextOverride(normalized); + if (!isNativeAliasCatalogEntry(normalized)) applyNativeOpenAiContextOverride(normalized); const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug); const e = ensureStrictCatalogFields(normalized, { preserveExactInputModalities: exactCombo, @@ -777,6 +854,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, @@ -786,7 +879,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 { @@ -814,6 +911,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); @@ -830,6 +928,7 @@ function writeRetainedCatalogSync({ multiAgentMode, exactComboSlugs, accountSelectors, + suppressedBareNativeSlugs, ); // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids @@ -857,6 +956,7 @@ function writeRetainedCatalogSync({ multiAgentMode, exactComboSlugs, accountSelectors, + suppressedBareNativeSlugs, ).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) : []; catalog.models = mergeCatalogEntriesForSync( @@ -874,6 +974,7 @@ function writeRetainedCatalogSync({ hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, + suppressedBareNativeSlugs, ); clampCatalogModelsToCodexSupport(catalog.models); diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 5194967eb2..7e678089e4 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -34,13 +34,15 @@ import { legacyCatalogBackupPath, parseCatalogJson, type RawCatalog, + type RawEntry, } from "./catalog/parsing"; import { buildCatalogEntries, + mergeCatalogModelsWithNativeRecovery, orderForSubagents, } from "./catalog/sync"; import { exactComboCatalogSlugs } from "./catalog/aggregation"; -import { disabledNativeSlugs } from "./catalog/metadata"; +import { disabledNativeSlugs, isNativeAliasCatalogEntry } from "./catalog/metadata"; import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime"; import { withCatalogWriteSerialization } from "./catalog-write-serialization"; import { @@ -162,6 +164,7 @@ function prepareCatalog( source: Extract, active: RawCatalog | null, routedModels: Awaited>, + nativeRecoverySources: readonly (readonly RawEntry[])[] = [], ): RawCatalog { const catalog = JSON.parse(JSON.stringify(source.catalog)) as RawCatalog; const template = findNativeTemplate(catalog); @@ -177,9 +180,29 @@ function prepareCatalog( name === "openai" && isCanonicalOpenAiForwardProvider(provider) )); const disabledNative = disabledNativeSlugs(config); + const preservingExistingRouted = ordered.length === 0; + const preservedNativeAliases = preservingExistingRouted + ? (active?.models ?? []).filter(entry => ( + typeof entry.slug === "string" + && exactComboSlugs.has(entry.slug) + && isNativeAliasCatalogEntry(entry) + )) + : []; + const preservedNativeAliasSlugs = new Set(preservedNativeAliases.flatMap(entry => ( + typeof entry.slug === "string" ? [entry.slug] : [] + ))); + const nativeCatalogModels = mergeCatalogModelsWithNativeRecovery( + active?.models ?? catalog.models ?? [], + [catalog.models ?? [], ...nativeRecoverySources], + ); const nativeSlugs = includeNativeOpenAi - ? [...new Set((active?.models ?? catalog.models ?? []).flatMap(entry => ( - typeof entry.slug === "string" && !entry.slug.includes("/") && !disabledNative.has(entry.slug) + ? [...new Set(nativeCatalogModels.flatMap(entry => ( + typeof entry.slug === "string" + && !entry.slug.includes("/") + && entry.owned_by !== COMBO_NAMESPACE + && !isNativeAliasCatalogEntry(entry) + && !disabledNative.has(entry.slug) + && !preservedNativeAliasSlugs.has(entry.slug) ? [entry.slug] : [] )))] : []; @@ -187,10 +210,12 @@ function prepareCatalog( template ? JSON.parse(JSON.stringify(template)) : null, nativeSlugs, ordered, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs, ); - if (entries.length === nativeSlugs.length) { + if (preservingExistingRouted) { const configuredProviders = new Set(enabledProviders.map(([name]) => name)); const preserved = (active?.models ?? []).filter(entry => { - if (typeof entry.slug !== "string" || !entry.slug.includes("/")) return false; + if (typeof entry.slug !== "string") return false; + if (isNativeAliasCatalogEntry(entry)) return exactComboSlugs.has(entry.slug); + if (!entry.slug.includes("/")) return false; const provider = entry.slug.slice(0, entry.slug.indexOf("/")); const description = typeof entry.description === "string" ? entry.description : ""; return configuredProviders.has(provider) || !description.startsWith("Routed via opencodex → "); @@ -255,7 +280,10 @@ export async function gatherCodexCatalogCandidate( } const active = catalogFrom(activeBytes); - const preparedCatalog = prepareCatalog(snapshot.config, source, active, routedModels); + const preparedCatalog = prepareCatalog(snapshot.config, source, active, routedModels, [ + catalogFrom(keyedBackupBytes)?.models ?? [], + catalogFrom(legacyBackupBytes)?.models ?? [], + ]); const preparedCatalogBytes = catalogBytes(preparedCatalog); const preparedCacheBytes = `${JSON.stringify({ fetched_at: "2000-01-01T00:00:00Z", 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 69b2d84353..7be8252c03 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -607,12 +607,14 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { } throw error; } - const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog"); + const { applyNativeVisibility, buildCatalogEntries, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog"); const includeNativeOpenAi = shouldIncludeNativeOpenAi(config); const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config); const nativeSlugs = includeNativeOpenAi ? nativeOpenAiSlugs() : []; const disabledNatives = disabledNativeSlugs(config); const disabledModels = new Set(config.disabledModels ?? []); + const shadowedNativeSlugs = configuredNativeAliasSlugs(config); + const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config); const accountSelectors = includeAccountBoundNativeOpenAi ? visibleCodexAccountSelectors(config) : []; @@ -665,7 +667,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { const catalogNativeSlugs = accountSelectors.length > 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, @@ -717,7 +719,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}) { ? NATIVE_OPENAI_MODELS.filter(slug => !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..43378a5f79 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); diff --git a/src/server/management/model-routes.ts b/src/server/management/model-routes.ts index de218f205f..08703816de 100644 --- a/src/server/management/model-routes.ts +++ b/src/server/management/model-routes.ts @@ -30,7 +30,7 @@ function readInputModalities(raw: unknown): { values?: string[]; error?: string return { values: raw as string[] }; } import type { CatalogModel } from "../../codex/catalog"; -import { catalogModelSlug, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; +import { catalogModelSlug, configuredNativeAliasSlugs, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog"; import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch"; import { getProviderLiveModelCount } from "../../codex/model-cache"; import { @@ -58,7 +58,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke import { deriveProviderPresets } from "../../providers/derive"; import { providerCodexAccountMode } from "../../providers/registry"; import { routedSlug, slugEquals } from "../../providers/slug-codec"; -import { COMBO_NAMESPACE, comboModelId, comboPublicModelId, preservesPhysicalComboProvider } from "../../combos"; +import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; @@ -255,17 +255,16 @@ export async function handleModelRoutes(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 71c200574b..511c40026e 100644 --- a/src/server/management/model-rows.ts +++ b/src/server/management/model-rows.ts @@ -76,11 +76,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 171628caff..139fdac2f5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -833,6 +833,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 ea34fa164a..c4cf91c54f 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -27,7 +27,15 @@ the rows being merged. This split is required because empty or partial provider preserve routed entries and genuine user-native rows from the file that will be overwritten; a bundled catalog never contains those rows. -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 3761e68e1c..1093eee39a 100644 --- a/tests/cli-headless-parity.test.ts +++ b/tests/cli-headless-parity.test.ts @@ -228,6 +228,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 7dfa2ace45..3ffade269b 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -138,6 +138,80 @@ describe("Codex catalog sync hardening", () => { expect(slugs).not.toContain("codex-auto-review"); // legacy dropped }); + test("native-alias sync omits disabled natives and restores pristine metadata after removal", () => { + const catalogPath = join(codexHome, "catalog.json"); + const codexCommand = createCodexCatalogFixture(codexHome); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(catalogPath, JSON.stringify({ + models: [ + nativeEntry("gpt-5.5", 0), + { ...nativeEntry("gpt-5.6-sol", 1), display_name: "Original Sol" }, + ], + }, null, 2) + "\n"); + + const r = runScript(codexHome, opencodexHome, ` + const { readFileSync } = require("node:fs"); + const { syncCatalogModels } = require("./src/codex/catalog"); + const catalogPath = ${JSON.stringify(catalogPath)}; + 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"] + } + }, + disabledModels: [ + "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" + ], + 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" }] + } + } + }; + await syncCatalogModels(config); + const first = JSON.parse(readFileSync(catalogPath, "utf8")); + delete config.combos; + config.disabledModels = config.disabledModels.filter(model => model !== "gpt-5.6-sol"); + await syncCatalogModels(config); + console.log(JSON.stringify({ first })); + `, { CODEX_CLI_PATH: codexCommand }); + expect(r.status).toBe(0); + + const first = JSON.parse(r.stdout).first.models as Array<{ + slug: string; + display_name?: string; + opencodex_catalog_kind?: string; + }>; + expect(first.filter(row => row.slug === "gpt-5.6-sol")).toEqual([ + expect.objectContaining({ + display_name: "Nova1 - codex-gpt-5.6-sol", + opencodex_catalog_kind: "combo-native-alias-v1", + }), + ]); + expect(first.some(row => row.slug === "gpt-5.5")).toBe(false); + + const restored = JSON.parse(readFileSync(catalogPath, "utf8")).models as typeof first; + expect(restored.filter(row => row.slug === "gpt-5.6-sol")).toEqual([ + expect.objectContaining({ display_name: "Original Sol" }), + ]); + expect(restored.find(row => row.slug === "gpt-5.6-sol")) + .not.toHaveProperty("opencodex_catalog_kind"); + }); + test("Gap A: an empty routed fetch preserves existing routed entries on disk", () => { 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 1f6896f8a2..9196ee7c38 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, comboCatalogOmissionReason, deriveComboCatalogModel, exactComboCatalogSlugs, filterCatalogVisibleModels, filterSupportedNativeSlugs, gatherRoutedModels as gatherRoutedModelsDirect, isDatedVariantId, isMediaGenerationModelId, loadBundledCodexCatalog, materializeBundledCodexCatalog, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, normalizeRoutedCatalogEntry, resetCatalogRuntimeStateForTests, resetOpenAiApiCatalogWarningStateForTests, shouldExposeRoutedModel } from "../src/codex/catalog"; +import { applyNativeVisibility, augmentRoutedModelsWithJawcodeMetadata, augmentRoutedModelsWithRegistryOpenAiApiRows, buildCatalogEntries, buildComboCatalogOmission, catalogModelSlug, clampCatalogModelsToCodexSupport, clampEntryToCodexSupportedEfforts, clampedDefaultEffort, 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 { withStubbedProviderFetch } from "./helpers/catalog-provider-fetch"; import { CURSOR_STATIC_MODELS, @@ -26,6 +26,7 @@ import type { NormalizedComboConfig } from "../src/combos/types"; import { enrichProviderFromRegistry } from "../src/providers/derive"; import { handleManagementAPI } from "../src/server/management-api"; import { OAUTH_PROVIDERS } from "../src/oauth"; +import { findNativeTemplate } from "../src/codex/catalog/parsing"; const originalFetch = globalThis.fetch; @@ -51,6 +52,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 }, @@ -293,6 +296,189 @@ describe("combo catalog capability intersection", () => { } }); + test("native-alias combos replace one bare native row without losing routed identity", () => { + const alias = "gpt-5.6-sol"; + const model = deriveComboCatalogModel( + "nova-sol", + normalizedCombo({ + alias, + nativeAlias: true, + displayName: "Nova1 - codex-gpt-5.6-sol", + }), + [memberA, memberB], + )!; + const exact = new Set([alias]); + + const live = buildCatalogEntries( + nativeTemplate(), + [alias, "gpt-5.5"], + [model], + undefined, + false, + "default", + exact, + ["main"], + new Set([alias, "gpt-5.5"]), + ); + const bare = live.filter(entry => entry.slug === alias); + expect(bare).toHaveLength(1); + expect(bare[0]).toMatchObject({ + display_name: "Nova1 - codex-gpt-5.6-sol", + owned_by: "combo", + opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND, + visibility: "list", + }); + const accountRow = live.find(entry => entry.slug === `main/${alias}`); + expect(accountRow).toMatchObject({ opencodex_catalog_kind: "account-selector-v1" }); + expect(accountRow?.owned_by).not.toBe("combo"); + expect(live.some(entry => entry.slug === "gpt-5.5")).toBe(false); + expect(live.find(entry => entry.slug === "main/gpt-5.5")).toBeDefined(); + + applyNativeVisibility(live, new Set([alias]), true); + expect(live.find(entry => entry.slug === alias)?.visibility).toBe("list"); + expect(live.find(entry => entry.slug === `main/${alias}`)?.visibility).toBe("hide"); + + const routedOnly = buildCatalogEntries( + nativeTemplate(), + [], + [model], + undefined, + false, + "default", + exact, + ); + const merged = mergeCatalogEntriesForSync( + [{ ...nativeTemplate(), slug: alias, display_name: "GPT-5.6-Sol" }], + routedOnly, + new Map(), + [], + false, + new Set(["nova-sol"]), + nativeTemplate(), + new Set([alias]), + new Set(["a", "b"]), + "default", + exact, + false, + true, + [], + new Set([alias, "gpt-5.5"]), + ); + expect(merged.filter(entry => entry.slug === alias)).toHaveLength(1); + expect(merged.find(entry => entry.slug === alias)).toMatchObject({ + display_name: "Nova1 - codex-gpt-5.6-sol", + opencodex_catalog_kind: CODEX_NATIVE_ALIAS_CATALOG_KIND, + visibility: "list", + }); + expect(merged.some(entry => entry.slug === "gpt-5.5")).toBe(false); + }); + + 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([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).toHaveBeenCalledWith(expect.stringContaining("preserving 1 existing routed entry")); + } finally { + warning.mockRestore(); + } + }); + + test("a disabled native alias cannot resurrect a native row and removal restores it", () => { + const alias = "gpt-5.6-sol"; + const native = { ...nativeTemplate(), slug: alias, display_name: "GPT-5.6-Sol" }; + const disabled = mergeCatalogEntriesForSync( + [native], + [], + new Map(), + [], + false, + new Set(), + nativeTemplate(), + new Set(["combo/nova-sol"]), + new Set(), + "default", + new Set(), + false, + true, + [], + new Set([alias]), + ); + expect(disabled.some(entry => entry.slug === alias)).toBe(false); + + const restored = mergeCatalogEntriesForSync( + [native], [], new Map(), [], false, + ); + expect(restored.find(entry => entry.slug === alias)).toMatchObject({ + display_name: "GPT-5.6-Sol", + visibility: "list", + }); + }); + + test("a bare native disable does not disable its native-alias combo", () => { + const model = deriveComboCatalogModel( + "nova-sol", + normalizedCombo({ + alias: "gpt-5.6-sol", + nativeAlias: true, + displayName: "Nova1 - Sol", + }), + [memberA, memberB], + )!; + const config = { + providers: { combo: { adapter: "openai-chat", baseUrl: "http://127.0.0.1" } }, + disabledModels: ["gpt-5.6-sol"], + }; + + expect(filterCatalogVisibleModels([model], config)).toEqual([model]); + expect(filterCatalogVisibleModels([model], { + ...config, + disabledModels: ["combo/nova-sol"], + })).toEqual([]); + }); + test("preserves exact combo capabilities under an alias", () => { const alias = "deepseek-v4-flash"; const model = deriveComboCatalogModel( @@ -579,6 +765,92 @@ 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("native aliases preserve explicit target capability limits", 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"], + 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, + maxInputTokens: 100_000, + inputModalities: ["text"], + reasoningEfforts: ["high"], + }); + expect(rows.find(row => row.provider === "combo" && row.id === "nova-sol")) + .not.toHaveProperty("defaultReasoningEffort"); + }); + test("exact combo slugs come only from current config", () => { expect(exactComboCatalogSlugs({ combos: { free: { targets: [{ provider: "a", model: "m1" }] }, @@ -965,6 +1237,20 @@ function nativeTemplate(): Record { } 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 74af96030a..ef81af1fb3 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"; @@ -205,6 +206,81 @@ 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 as never, { 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")).toBeDefined(); + 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..62548c4b9a 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 an unlabeled native alias 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,62 @@ 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", + }); + 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 +640,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 +698,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 43a6c00366..d6c773159f 100644 --- a/tests/combo-workspace-data.test.ts +++ b/tests/combo-workspace-data.test.ts @@ -11,6 +11,7 @@ import { isValidComboId, parseComboList, toPutBody, + updateComboAliasDraft, validateComboDraft, } from "../gui/src/combo-workspace-data"; @@ -27,6 +28,8 @@ function combo(overrides: Partial = {}): ComboItem { id: "free", model: "combo/free", alias: null, + nativeAlias: false, + displayName: null, strategy: "failover", stickyLimit: 1, defaultEffort: "medium", @@ -86,6 +89,8 @@ describe("combo-workspace-data", () => { id: "fallback", model: "combo/fallback", alias: null, + nativeAlias: false, + displayName: null, strategy: "failover", stickyLimit: 1, defaultEffort: null, @@ -95,6 +100,8 @@ describe("combo-workspace-data", () => { id: "weighted", model: "combo/weighted", alias: null, + nativeAlias: false, + displayName: null, strategy: "round-robin", stickyLimit: 4, defaultEffort: "high", @@ -236,12 +243,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"); @@ -336,6 +398,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 f2705d6437..f13e961607 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: { @@ -389,6 +403,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"); @@ -424,12 +473,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<{ @@ -502,6 +583,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..b6681e51da 100644 --- a/tests/model-visibility-management-api.test.ts +++ b/tests/model-visibility-management-api.test.ts @@ -259,6 +259,47 @@ 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).toContain("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-model-toggle.test.ts b/tests/native-model-toggle.test.ts index d2f9615ab9..340a2f9f30 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 a59256c17f..b0b1403df9 100644 --- a/tests/native-profile-drain-server.test.ts +++ b/tests/native-profile-drain-server.test.ts @@ -280,14 +280,16 @@ 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; + const upstreamCloseDeadline = Date.now() + 2_000; + while (upstreamCloses < 1 && Date.now() < upstreamCloseDeadline) await Bun.sleep(10); + expect(upstreamCloses).toBe(1); + expect(getNativeMainProfileRequestCount()).toBe(0); const afterClose = await handleNativeProfileAPI( switchRequest(), new URL("http://localhost/api/native-main-profiles/switch"),