diff --git a/.github/pr-assets/account-model-picker-desktop.png b/.github/pr-assets/account-model-picker-desktop.png new file mode 100644 index 0000000000..6d92c5b72f Binary files /dev/null and b/.github/pr-assets/account-model-picker-desktop.png differ diff --git a/.github/pr-assets/account-model-picker-mobile.png b/.github/pr-assets/account-model-picker-mobile.png new file mode 100644 index 0000000000..31a1348053 Binary files /dev/null and b/.github/pr-assets/account-model-picker-mobile.png differ diff --git a/devlog/_plan/260910_account_model_picker/000_plan.md b/devlog/_plan/260910_account_model_picker/000_plan.md new file mode 100644 index 0000000000..1a5ce04182 --- /dev/null +++ b/devlog/_plan/260910_account_model_picker/000_plan.md @@ -0,0 +1,19 @@ +# Selective Codex account models + +Understood as: register selected account/model pairs in OpenCodex for review harnesses while preserving common pool-routed native models; validate locally and submit an upstream dev PR. + +## Scope and rationale +Some models are available only to particular accounts. Review automation needs an explicit eligible-account selector without multiplying every model by every account. This feature selects catalog entries; it grants no provider entitlement and promises no change to refusals. + +Reuse existing account namespaces and exact-account routing. Add an optional per-selector model allowlist to settings, extend the existing advanced-settings control, and keep common native rows visible in selective mode. Existing configurations without an allowlist retain their legacy projection until selection is saved. No credentials, routing policy, Daybreak wire normalization, or running service changes. + +## Plan and audit +- Configuration validates public selector keys and bare native model IDs; absent means legacy all, empty object means none. +- GET/PUT settings exposes choices and persists selections with existing rollback and catalog convergence. +- Catalog consumers filter generated rows by the same allowlist. Selective mode leaves bare rows visible. Entitlement gates and exact-account failures remain unchanged. +- UI reuses the existing card, toggle, forms and locale files; account/model choices expand beneath the toggle. Quiet developer settings: variance 2, motion 1, density D5. No new visual system. +- Tests: configuration validation, save/rollback, selected-only catalog and common rows, exact-account regression, UI save/toggle/failure; local typecheck/full tests, GUI checks/build, docs build, privacy scan; independent security/correctness review and browser screenshots before PR. + +Audit: a display allowlist must never become an authorization source. Unknown/deleted selectors must not be advertised. Disabling visibility must not remove routing bindings. Main means current main login, not a permanently pinned email identity. + +User refinement: the original switch keeps its original default/legacy behavior. A second Customize per account toggle opts into selective projection; switching it off restores legacy behavior. diff --git a/docs-site/src/content/docs/fr/guides/codex-app-models.md b/docs-site/src/content/docs/fr/guides/codex-app-models.md index fb81b9597e..f0d1ae198c 100644 --- a/docs-site/src/content/docs/fr/guides/codex-app-models.md +++ b/docs-site/src/content/docs/fr/guides/codex-app-models.md @@ -12,9 +12,11 @@ Les entrées OpenAI utilisent deux routes d'identification : la connexion Codex clé API avec espace de noms `openai-apikey/`. Le simple passage de `codexAccountMode` entre Pool et Direct ne change pas les identifiants du sélecteur. Toutefois, lorsque les lignes qualifiées par compte sont activées avec `codexAccountPickerEnabled` et que `codexAccountNamespaces` contient des sélecteurs admissibles -dont les comptes associés existent toujours, opencodex ajoute une ligne -`/` distincte pour chaque compte associé et masque les lignes natives non -qualifiées du sélecteur Codex. Les libellés des sélecteurs sont des noms publics choisis par l'utilisateur et +dont les comptes associés existent toujours, le résultat dépend de `codexAccountPickerModels`. Lorsque cette +table est omise, le comportement historique ajoute une ligne `/` pour chaque +modèle pris en charge et masque les lignes natives non qualifiées du sélecteur Codex. Lorsque la table est +présente, les modèles natifs du groupe commun restent visibles sous forme de lignes non qualifiées, et seules +les paires de modèles et de comptes sélectionnées reçoivent des lignes qualifiées. Les libellés des sélecteurs sont des noms publics choisis par l'utilisateur et n'ont aucune signification intégrée quant au rôle du compte. Choisir une ligne qualifiée utilise exclusivement le compte associé, ne change pas le compte Pool actif et échoue de façon fermée au lieu de changer de compte si la cible n'est pas disponible. Si le catalogue Codex propre à un compte contient un identifiant visible de @@ -26,6 +28,8 @@ filtre les entrées mal formées ; cela ne prouve pas que l'identifiant provient cache appartient à l'utilisateur. Consultez les [sélecteurs exacts de compte Codex](/fr/reference/configuration/routing/#sélecteurs-exacts-de-comptes-codex). +Pour activer cette option, choisir les modèles, enregistrer ou revenir au mode précédent, consultez les [étapes de configuration en anglais](/guides/codex-app-models/#select-only-particular-account-models). + `gpt-daybreak-blue-latest` suit cette règle d'observation uniquement pour les lignes qualifiées par compte et n'est pas ajouté à la liste d'autorisation native non qualifiée. Une entrée `customModels` distincte et explicite peut exposer le même identifiant transmis comme `openai/gpt-daybreak-blue-latest` par @@ -139,7 +143,7 @@ approximation fondée sur un ancien modèle d'entrée. | Route | Identifiants du sélecteur et métadonnées du catalogue | | --- | --- | | Connexion Codex (lignes qualifiées par compte désactivées) | Identifiants natifs non qualifiés comme `gpt-5.6-sol`, `gpt-5.6-terra` et `gpt-5.6-luna` ; Pool ou Direct est choisi avec `codexAccountMode`. Les lignes GPT-5.6 utilisent une fenêtre de catalogue de 372 000 jetons. | -| Connexion Codex (lignes qualifiées par compte activées avec des sélecteurs admissibles) | Une ligne `/` par sélecteur admissible et modèle natif pris en charge ; chaque ligne utilise exclusivement le compte associé, et les lignes natives non qualifiées sont masquées dans le sélecteur. Les métadonnées natives et les fenêtres de contexte sont préservées. | +| Connexion Codex (lignes qualifiées par compte activées avec des sélecteurs admissibles) | Lorsque `codexAccountPickerModels` est omis, chaque sélecteur admissible reçoit tous les modèles natifs pris en charge et les lignes natives non qualifiées sont masquées. Lorsque la table est présente, les modèles natifs du groupe commun restent visibles sous forme de lignes non qualifiées et seules les paires de modèles et de sélecteurs choisies reçoivent des lignes qualifiées. Chaque ligne qualifiée utilise exclusivement le compte associé. Les métadonnées natives et les fenêtres de contexte sont préservées. | | Connexion Codex (ligne Daybreak transférée explicitement) | `openai/gpt-daybreak-blue-latest` uniquement lorsque l'entrée `customModels` exacte est configurée sur le fournisseur canonique `openai`. Elle conserve l'identifiant Daybreak transmis et utilise l'instantané de capacités Sol épinglé (contexte de 372 000 jetons ; compactage automatique à 334 800 jetons). | | OpenAI (clé API) | Exactement dix lignes avec espace de noms : `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, les trois identifiants virtuels `*-pro` et les deux alias Daybreak (contexte de 1 050 000 jetons ; entrée maximale de 922 000 jetons pour les dix) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (1 050 000) | 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 6f9b756789..923b60ee7f 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -11,9 +11,11 @@ OpenAI entries use two credential routes: native Codex login and the namespaced `openai-apikey/` API-key transport. Changing `codexAccountMode` between Pool and Direct by itself does not change picker ids. When account-qualified picker rows are enabled by `codexAccountPickerEnabled` and `codexAccountNamespaces` has eligible selectors whose -mapped accounts still exist, however, -opencodex adds separate `/` rows for the mapped accounts and hides -the bare native rows from the Codex picker. Selector labels are user-chosen public names with no +mapped accounts still exist, the result depends on `codexAccountPickerModels`. When that map is +omitted, legacy behavior adds separate `/` rows for every supported +model and hides the bare native rows from the Codex picker. When the map is present, common pooled +native models remain visible as bare rows, and only the selected model and account pairs receive +qualified rows. Selector labels are user-chosen public names with no built-in account-role meaning. Selecting a qualified row uses only its mapped account, does not change the active Pool account, and fails closed instead of switching accounts when the target is unavailable. If Codex's account-scoped catalog contains a visible, API-supported OpenAI-family id @@ -197,7 +199,7 @@ metadata instead of an older-template approximation. | Route | Picker ids and catalog metadata | | --- | --- | | Codex login (account-qualified rows disabled) | Bare native ids such as `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`; Pool or Direct is selected through `codexAccountMode`. GPT-5.6 rows use a 922,000-token catalog window. | -| Codex login (account-qualified rows enabled with eligible selectors) | One `/` row per eligible selector and supported native model; each row uses only its mapped account, and bare native rows are hidden from the picker. Native metadata and context windows are preserved. | +| Codex login (account-qualified rows enabled with eligible selectors) | With `codexAccountPickerModels` omitted, every eligible selector receives every supported native model and bare native rows are hidden. With the map present, common pooled native models remain visible as bare rows and only selected model and selector pairs receive qualified rows. Each qualified row uses only its mapped account. Native metadata and context windows are preserved. | | Codex login (explicit Daybreak forward row) | `openai/gpt-daybreak-blue-latest` only when the exact `customModels` row is configured on the canonical `openai` provider. It keeps the Daybreak wire id and uses the pinned Sol capability snapshot (922,000 context; 829,800 automatic compaction). | | OpenAI (API key) | Exactly ten namespaced rows: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, the three `*-pro` virtual ids, and the two Daybreak aliases (1,050,000 context; 922,000 max input for all ten) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (922,000) | @@ -321,3 +323,18 @@ opencodex rewrites `models_cache.json` with a deliberately stale cache wrapper w visibility, priority, or metadata changes, so the next Codex model refresh reads the new catalog. After a catalog or model-cache write, OpenCodex invalidates its cached app-server observation so the next request checks process freshness again. A configuration sync also invalidates the observation when catalog contents are unchanged. This refresh does not restart Codex processes. + + +## Select only particular account models + +In **Advanced settings**, enable **Target a specific Codex account from the model picker**, then +**Customize models per account**. Select the models to expose for each account and save. +Common native models remain in the picker and continue to use the configured Direct/Pool +routing. Selected entries such as `main/gpt-daybreak-blue-latest` use only the mapped account. +This is useful for review harnesses when model access differs between accounts; selecting a +model does not grant upstream access or guarantee that a review will be accepted. + +The original switch keeps its existing behavior until customization is enabled. Turning +customization off restores the original all-account projection. Turning the original switch +off hides account entries without deleting the routing bindings or saved selection. +The `main` selector follows the current main Codex login, not a permanently bound email address. 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 93385300ec..c95269e1e5 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 @@ -8,7 +8,9 @@ opencodex は Codex アプリにパッチを適用しません。Codex CLI/TUI リリースは renderer 側で追加の remote allowlist を適用し、routed row を picker から除外する ことがあります。明示的な `nativeAlias: true` combo が、この上流不具合向けの互換モードです。 -OpenAI エントリには、ネイティブ Codex ログインと、名前空間付きの `openai-apikey/` API キーという 2 つの資格情報ルートがあります。`codexAccountMode` だけを Pool と Direct の間で変更しても、ピッカー ID は変わりません。ただし、`codexAccountPickerEnabled` によって account-qualified picker 行が有効で、`codexAccountNamespaces` に対象アカウントが存在する selector がある場合、opencodex は対応するアカウントごとに `/` 行を追加し、ピッカーでは bare native 行を非表示にします。Selector 名はユーザーが決める公開ラベルであり、組み込みのアカウント role の意味はありません。`selector` 付きの行を選択すると、対応付けられたアカウントだけが使用され、アクティブな Pool アカウントは変更されません。対象を利用できない場合、別のアカウントへ切り替えずにリクエストが失敗します。詳しくは [Codex アカウントの明示的な selector](/reference/configuration/routing/#exact-codex-account-selectors) を参照してください。 +OpenAI エントリには、ネイティブ Codex ログインと、名前空間付きの `openai-apikey/` API キーという 2 つの資格情報ルートがあります。`codexAccountMode` だけを Pool と Direct の間で変更しても、ピッカー ID は変わりません。ただし、`codexAccountPickerEnabled` によって account-qualified picker 行が有効で、`codexAccountNamespaces` に対象アカウントが存在する selector がある場合、結果は `codexAccountPickerModels` によって変わります。この map を省略すると、従来の動作として、すべての対応モデルに `/` 行が追加され、ピッカーの bare native 行は非表示になります。map が存在する場合、共通プールの native model は bare 行として表示されたままで、選択したモデルとアカウントの組み合わせだけに qualified 行が追加されます。Selector 名はユーザーが決める公開ラベルであり、組み込みのアカウント role の意味はありません。`selector` 付きの行を選択すると、対応付けられたアカウントだけが使用され、アクティブな Pool アカウントは変更されません。対象を利用できない場合、別のアカウントへ切り替えずにリクエストが失敗します。詳しくは [Codex アカウントの明示的な selector](/reference/configuration/routing/#exact-codex-account-selectors) を参照してください。 + +有効化、モデルの選択と保存、元のモードへの戻し方は、[英語版の設定手順](/guides/codex-app-models/#select-only-particular-account-models)を参照してください。 `codexAccountNamespaces` map が空の場合、account-qualified picker 行は off です。空でない map で `codexAccountPickerEnabled` を省略すると、後方互換性のため有効として扱われます。`false` にすると、mapping を削除せず、明示的な `/` routing も無効にせずに、生成された qualified 行を非表示にして picker の bare native 行を復元します。 @@ -53,7 +55,7 @@ visibility = "list" |ルート |ピッカー ID とカタログのメタデータ | | --- | --- | | Codex ログイン (account-qualified 行が無効) | `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` などの bare native id を表示し、`codexAccountMode` に従って Pool または Direct を使用します。GPT-5.6 行のカタログ ウィンドウは 922,000 トークンです。 | -| Codex ログイン (account-qualified 行が有効で、有効な selector あり) | 有効な selector とサポート対象 native model の各組み合わせに `/` 行を表示します。各行は対応付けられたアカウントだけを使用し、bare native 行はピッカーで非表示になります。Native metadata と context window は保持されます。 | +| Codex ログイン (account-qualified 行が有効で、有効な selector あり) | `codexAccountPickerModels` を省略すると、各 selector にすべての対応 native model の行が追加され、bare native 行は非表示になります。map が存在する場合、共通プールの native model は bare 行として表示されたままで、選択したモデルと selector の組み合わせだけに qualified 行が追加されます。各 qualified 行は対応付けられたアカウントだけを使用します。Native metadata と context window は保持されます。 | | OpenAI (API キー) |正確に 8 つの名前空間行: `gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、および 3 つの `*-pro` 仮想 ID (コンテキスト 922,000、8 つすべての最大入力 922,000) | |オープンルーター | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna` (922,000) | | Cursor | 静的フォールバックには `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna` (1,000,000) と、Grok 4.5 / 4.6 の通常・Fast 行 (500,000) が含まれます。4.6 は `xhigh` も公開し、ライブアカウントの検出によって表示される行が決まります。 | 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 0642712082..6d0a75d952 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 @@ -11,13 +11,16 @@ allowlist를 적용해 routed row를 picker에서 제거할 수 있습니다. OpenAI 항목에는 네이티브 Codex 로그인과 네임스페이스가 붙은 `openai-apikey/` API key 경로라는 두 가지 credential 경로가 있습니다. `codexAccountMode`만 Pool과 Direct 사이에서 바꾸는 것은 선택기 id를 바꾸지 않습니다. 하지만 `codexAccountPickerEnabled`로 계정 한정 선택기 행이 활성화되어 있고 -`codexAccountNamespaces`에 대상 계정이 존재하는 selector가 있으면, -opencodex는 매핑된 계정별로 `/` 행을 추가하고 선택기에서 bare native 행을 -숨깁니다. Selector 이름은 사용자가 정하는 공개 label이며 내장된 계정 역할 의미가 없습니다. `selector`가 +`codexAccountNamespaces`에 대상 계정이 존재하는 selector가 있으면 결과는 `codexAccountPickerModels`에 따라 +달라집니다. 이 map을 생략하면 기존 동작대로 모든 지원 모델에 `/` 행을 추가하고 +선택기에서 bare native 행을 숨깁니다. map이 있으면 공통 풀 native model은 bare 행으로 계속 표시되고, 선택한 +모델과 계정 조합에만 qualified 행을 추가합니다. Selector 이름은 사용자가 정하는 공개 label이며 내장된 계정 역할 의미가 없습니다. `selector`가 붙은 행을 선택하면 매핑된 계정만 사용하고 활성 Pool 계정은 바뀌지 않습니다. 대상 계정을 사용할 수 없으면 다른 계정으로 전환하지 않고 요청이 실패합니다. 자세한 내용은 [명시적 Codex 계정 selector](/reference/configuration/routing/#exact-codex-account-selectors)를 참고하세요. +활성화, 모델 선택·저장, 기존 모드로 되돌리는 방법은 [영문 설정 절차](/guides/codex-app-models/#select-only-particular-account-models)를 참고하세요. + 계정 한정 행에서 `gpt-daybreak-blue-latest`는 계정 카탈로그에 관측됐을 때만 보존되며 bare native allowlist에는 추가되지 않습니다. 이와 별개로 canonical Codex 로그인 forward provider에 다음과 같은 명시적 `customModels` 항목을 두면 같은 wire id를 `openai/gpt-daybreak-blue-latest`로 노출할 수 있습니다. @@ -106,7 +109,7 @@ GPT-5.6에만 사용합니다. 오래된 템플릿으로 근사하지 않고 모 | 경로 | 선택기 id와 카탈로그 메타데이터 | | --- | --- | | Codex 로그인(계정 한정 선택기 행 비활성) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` 같은 bare native id를 표시하고 `codexAccountMode`에 따라 Pool 또는 Direct를 사용합니다. GPT-5.6 행의 카탈로그 창은 922,000토큰입니다. | -| Codex 로그인(계정 한정 선택기 행 활성, 유효한 selector 있음) | 유효한 selector와 지원되는 native model의 각 조합마다 `/` 행을 표시합니다. 각 행은 매핑된 계정만 사용하며 bare native 행은 선택기에서 숨깁니다. Native metadata와 context window는 보존됩니다. | +| Codex 로그인(계정 한정 선택기 행 활성, 유효한 selector 있음) | `codexAccountPickerModels`를 생략하면 각 유효한 selector에 모든 지원 native model 행을 추가하고 bare native 행을 숨깁니다. map이 있으면 공통 풀 native model은 bare 행으로 계속 표시되고 선택한 모델과 selector 조합에만 qualified 행을 추가합니다. 각 qualified 행은 매핑된 계정만 사용합니다. Native metadata와 context window는 보존됩니다. | | Codex 로그인(명시적 Daybreak forward 행) | canonical `openai` provider에 정확한 `customModels` 항목이 있을 때만 `openai/gpt-daybreak-blue-latest`를 표시합니다. Daybreak wire id를 유지하고 고정된 Sol capability snapshot(컨텍스트 922,000; 자동 압축점 922,000)을 사용합니다. | | OpenAI(API key) | 정확히 열 개의 네임스페이스 행: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, 세 개의 `*-pro` 가상 id, 두 Daybreak 별칭 (모두 컨텍스트 922,000; 최대 입력 922,000) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (922,000) | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 5f3fc99649..f05f9714b8 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -1003,3 +1003,22 @@ or expiry does not extend the history-recovery contract. Sender and recipient on routed Responses are context for the receiving model, not a new machine-readable routing protocol. Tool routing continues to use the existing collaboration contracts. + + +### Selective account-model picker + +`codexAccountPickerModels?: Record` selects bare native model IDs per +public `codexAccountNamespaces` selector. With this field present, selected account entries +are added alongside common native picker rows. An empty object adds none. Omit the field +to retain the legacy all-account projection. `codexAccountPickerEnabled: false` still hides +all generated entries. This preference never changes exact-account routing or entitlements. + +```json +{ + "codexAccountNamespaces": { "main": "@main" }, + "codexAccountPickerEnabled": true, + "codexAccountPickerModels": { "main": ["gpt-daybreak-blue-latest"] } +} +``` + +A selected gated model appears only when the existing account entitlement checks admit it. diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index b7fdbf072f..a13bf91ef8 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -398,7 +398,7 @@ whether to star the repository. `GET /api/settings` reports the effective `codexAccountPickerEnabled` boolean. A `PUT` containing that strict boolean initializes privacy-safe account selectors when enabling an empty map, preserves existing selector labels when disabling or re-enabling, persists first, and then requests one bounded -catalog convergence only when effective picker visibility changed. The successful response includes +catalog convergence only when effective picker visibility or the selected account models changed. The successful response includes `catalogRefreshPending`: `false` means the catalog commit completed (or no refresh was needed), while `true` means the setting was saved but `POST /api/sync` should be used to retry the catalog refresh. Persistence or selector-allocation failure rolls the in-memory settings back and does not run @@ -478,3 +478,23 @@ Direct HTTP is most useful for integrations that need the exact endpoint contrac ## Remote sessions and data-key rotation `POST /api/keys/rotate {id}` starts a ten-minute overlap and returns the new data secret once. `POST /api/keys/rotate/commit {id,rotationId}` commits it; `DELETE /api/keys/rotate {id,rotationId}` aborts it. All require management authentication; data keys cannot call them. `POST /api/session/logout` requires the current `gui-session`, matching Origin, and CSRF. An admin token receives 403 and can never mint or exchange into a consent session. + + +### Selective Codex account models + +`GET /api/settings` also returns `codexAccountPickerModels` (a public-selector-to-model-array +map, or `null` for legacy mode) and `codexAccountPickerOptions` (objects with `selector` +and `models`, containing the known candidate model IDs for editing). Candidate choices are +not proof of upstream permission; catalog entitlement checks still apply. + +`PUT /api/settings` accepts `codexAccountPickerModels` alone or with the existing switch. +An object enables selective mode, `{}` selects none, and `null` removes the preference to +restore legacy behavior. Invalid keys, non-array values, and non-native model IDs return 400 before writing. +An account selector returns 400 only if it is not a current candidate, a retained +`codexAccountNamespaces` binding, or a key in the previously saved selection map. An atomic enable-and-select request validates +against the newly initialized selectors and persists those same bindings. Choices for retained +bindings whose accounts are no longer selectable, and saved choices for removed or renamed +bindings, are discarded on save, so stale drafts do not +block editing remaining accounts. Previously saved model choices that are no longer eligible +are also discarded on the next save; newly added unavailable model IDs still return 400. Saving converges the catalog when the preference +changes; failure rolls the in-memory preference back. No credential fields are returned. 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 e208ab4b0b..77a79c515d 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 @@ -11,15 +11,18 @@ Codex Desktop применяют в renderer дополнительный remote Записи OpenAI используют два credential-транспорта: нативный вход Codex и namespaced-транспорт API-ключа `openai-apikey/`. Само по себе переключение `codexAccountMode` между Pool и Direct не меняет id в picker'е. Однако если `codexAccountPickerEnabled` включает строки picker'а с -указанием аккаунта и в -`codexAccountNamespaces` есть подходящие селекторы, -opencodex добавляет для сопоставленных аккаунтов отдельные строки -`/` и скрывает bare native-строки из picker'а. Имена селекторов — +указанием аккаунта и в `codexAccountNamespaces` есть подходящие селекторы, результат зависит от +`codexAccountPickerModels`. Если эта map не задана, прежнее поведение добавляет для каждой +поддерживаемой модели отдельные строки `/` и скрывает bare +native-строки из picker'а. Если map задана, нативные модели общего пула остаются видимыми как bare +строки, а qualified-строки создаются только для выбранных пар модели и аккаунта. Имена селекторов — это публичные метки, которые выбирает пользователь; встроенного смысла роли аккаунта у них нет. Выбор строки с селектором использует только сопоставленный аккаунт, не меняет активный аккаунт Pool и при недоступности цели завершается ошибкой без переключения на другой аккаунт. Подробнее см. в разделе [Точные селекторы аккаунтов Codex](/reference/configuration/routing/#exact-codex-account-selectors). +Порядок включения, выбора и сохранения моделей, а также возврата к прежнему режиму описан в [инструкции на английском](/guides/codex-app-models/#select-only-particular-account-models). + Если map `codexAccountNamespaces` пуста, строки picker'а с указанием аккаунта выключены. Если при непустой map поле `codexAccountPickerEnabled` не задано, они считаются включёнными для обратной совместимости. Значение `false` скрывает созданные account-qualified строки и возвращает bare @@ -86,7 +89,7 @@ per-model identity и метаданные вместо приближения | Маршрут | Id в селекторе и метаданные каталога | | --- | --- | | Вход Codex (строки с указанием аккаунта выключены) | Bare native-id, например `gpt-5.6-sol`, `gpt-5.6-terra` и `gpt-5.6-luna`; Pool или Direct выбирается через `codexAccountMode`. У строк GPT-5.6 окно каталога 922 000 токенов. | -| Вход Codex (строки с указанием аккаунта включены и есть подходящие селекторы) | По одной строке `/` для каждой пары подходящего селектора и поддерживаемой нативной модели; каждая строка использует только сопоставленный аккаунт, а bare native-строки скрыты из picker'а. Нативные метаданные и окна контекста сохраняются. | +| Вход Codex (строки с указанием аккаунта включены и есть подходящие селекторы) | Если `codexAccountPickerModels` не задана, каждый подходящий селектор получает все поддерживаемые нативные модели, а bare native-строки скрыты. Если map задана, нативные модели общего пула остаются видимыми как bare строки, а qualified-строки создаются только для выбранных пар модели и селектора. Каждая qualified-строка использует только сопоставленный аккаунт. Нативные метаданные и окна контекста сохраняются. | | OpenAI (API key) | Ровно восемь namespaced-строк: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna и три виртуальных id `*-pro` (контекст 922,000; максимум входа 922,000 у всех восьми) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (922,000) | | Cursor | Статический fallback включает `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra` и `cursor/gpt-5.6-luna` (1,000,000), а также обычные/Fast-строки Grok 4.5 и 4.6 (500,000). Для 4.6 доступен ещё `xhigh`; какие строки останутся видимыми, решает live-discovery аккаунта. | diff --git a/docs-site/src/content/docs/tr/guides/codex-app-models.md b/docs-site/src/content/docs/tr/guides/codex-app-models.md index 2ddd5bd63a..76befa3394 100644 --- a/docs-site/src/content/docs/tr/guides/codex-app-models.md +++ b/docs-site/src/content/docs/tr/guides/codex-app-models.md @@ -14,9 +14,12 @@ alanlı `openai-apikey/` API anahtarı aktarımı. `codexAccountMode`'u P ve Direct arasında değiştirmek tek başına seçici kimliklerini değiştirmez. Bununla birlikte, hesap nitelikli seçici satırları `codexAccountPickerEnabled` tarafından etkinleştirildiğinde ve `codexAccountNamespaces`, eşlenen hesapları -hala mevcut olan uygun seçicilere sahip olduğunda, opencodex eşlenen hesaplar -için ayrı `/` satırları ekler ve yalın yerel -satırları Codex seçicisinden gizler. Seçici etiketleri, yerleşik bir hesap rolü +hala mevcut olan uygun seçicilere sahip olduğunda sonuç `codexAccountPickerModels` +ayarına bağlıdır. Bu harita atlanırsa eski davranış, desteklenen her model için +`/` satırları ekler ve yalın yerel satırları Codex +seçicisinden gizler. Harita mevcutsa ortak havuzdaki yerel modeller yalın satırlar +olarak görünmeye devam eder ve yalnızca seçilen model ve hesap çiftlerine nitelikli +satırlar eklenir. Seçici etiketleri, yerleşik bir hesap rolü anlamı olmayan, kullanıcı tarafından seçilen genel adlardır. Nitelikli bir satırı seçmek yalnızca eşlenen hesabını kullanır, aktif Havuz hesabını değiştirmez ve hedef kullanılamadığında hesap değiştirmek yerine kapalı olarak @@ -30,6 +33,8 @@ listesine eklenmez. Satır, gerçek bir katalog satırının sahip olduğu alan yanıtından geldiğini kanıtlamaz. Bkz. [Tam Codex hesap seçicileri](/tr/reference/configuration/routing/#exact-codex-account-selectors). +Etkinleştirme, model seçme ve kaydetme ile önceki moda dönme adımları için [İngilizce kurulum yönergelerine](/guides/codex-app-models/#select-only-particular-account-models) bakın. + `gpt-daybreak-blue-latest`, hesap nitelikli satırlar için bu yalnızca gözlem kuralını takip eder ve yalın yerel izin listesine eklenmez. Ayrı, açık bir `customModels` girdisi, kurallı Codex girişi iletme sağlayıcısı aracılığıyla @@ -154,7 +159,7 @@ meta verileri sağladığı GPT-5.6 için kullanılır. | Rota | Seçici kimlikleri ve katalog meta verileri | | --- | --- | | Codex girişi (hesap nitelikli satırlar devre dışı) | `gpt-5.6-sol`, `gpt-5.6-terra` ve `gpt-5.6-luna` gibi yalın yerel kimlikler; Pool veya Direct `codexAccountMode` aracılığıyla seçilir. GPT-5.6 satırları 922.000 tokenlik bir katalog penceresi kullanır. | -| Codex girişi (uygun seçicilerle hesap nitelikli satırlar etkin) | Uygun seçici ve desteklenen yerel model başına bir `/` satırı; her satır yalnızca eşlenen hesabını kullanır ve yalın yerel satırlar seçiciden gizlenir. Yerel meta veriler ve bağlam pencereleri korunur. | +| Codex girişi (uygun seçicilerle hesap nitelikli satırlar etkin) | `codexAccountPickerModels` atlanırsa her uygun seçici desteklenen tüm yerel modelleri alır ve yalın yerel satırlar gizlenir. Harita mevcutsa ortak havuzdaki yerel modeller yalın satırlar olarak görünmeye devam eder ve yalnızca seçilen model ve seçici çiftlerine nitelikli satırlar eklenir. Her nitelikli satır yalnızca eşlenen hesabını kullanır. Yerel meta veriler ve bağlam pencereleri korunur. | | Codex girişi (açık Daybreak iletme satırı) | Yalnızca tam `customModels` satırı kurallı `openai` sağlayıcısında yapılandırıldığında `openai/gpt-daybreak-blue-latest`. Daybreak hat kimliğini korur ve sabitlenmiş Sol yetenek anlık görüntüsünü kullanır (922.000 bağlam; 829.800 otomatik sıkıştırma). | | OpenAI (API anahtarı) | Tam olarak on ad alanlı satır: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, üç `*-pro` sanal kimliği ve iki Daybreak takma adı (onunun tümü için 922.000 bağlam; 922.000 maksimum girdi) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (922.000) | @@ -314,4 +319,3 @@ ocx sync opencodex, katalog görünürlüğü, önceliği veya meta verileri her değiştiğinde `models_cache.json` dosyasını kasıtlı olarak eski bir önbellek sarmalayıcısıyla yeniden yazar, böylece bir sonraki Codex model yenilemesi yeni kataloğu okur. - 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 241b812252..c282fc2a83 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 @@ -7,7 +7,9 @@ opencodex 不会修改 Codex App。它会写入 Codex CLI/TUI 使用的同一套 app-server 会读取这份共享状态,但部分 Codex Desktop 版本还会在 renderer 中应用第二层远程 allowlist,因此仍可能从选择器里删掉路由模型。 -OpenAI 条目有两种凭据通道:原生 Codex 登录,以及命名空间化的 `openai-apikey/` API key 通道。仅在 Pool 与 Direct 之间切换 `codexAccountMode` 不会改变选择器 id。但当 `codexAccountPickerEnabled` 启用了账户限定的选择器行,且 `codexAccountNamespaces` 中有目标账户存在的 selector 时,opencodex 会为映射账户添加独立的 `/` 行,并在选择器中隐藏裸原生行。Selector 名称是用户自定义的公开标签,没有内置的账户角色含义。选择带 `selector` 的行只会使用映射账户,不会更改当前 Pool 账户;目标不可用时,请求会直接失败,不会切换到其他账户。详情请参阅[精确 Codex 账户选择器](/reference/configuration/routing/#exact-codex-account-selectors)。 +OpenAI 条目有两种凭据通道:原生 Codex 登录,以及命名空间化的 `openai-apikey/` API key 通道。仅在 Pool 与 Direct 之间切换 `codexAccountMode` 不会改变选择器 id。但当 `codexAccountPickerEnabled` 启用了账户限定的选择器行,且 `codexAccountNamespaces` 中有目标账户存在的 selector 时,结果取决于 `codexAccountPickerModels`。省略该映射时,系统沿用旧行为:为每个受支持模型添加独立的 `/` 行,并在选择器中隐藏裸原生行。提供该映射时,公共池原生模型仍以裸行显示,只有所选的模型和账户组合会添加限定行。Selector 名称是用户自定义的公开标签,没有内置的账户角色含义。选择带 `selector` 的行只会使用映射账户,不会更改当前 Pool 账户;目标不可用时,请求会直接失败,不会切换到其他账户。详情请参阅[精确 Codex 账户选择器](/reference/configuration/routing/#exact-codex-account-selectors)。 + +启用、选择并保存模型以及恢复原有模式的步骤,请参阅[英文设置说明](/guides/codex-app-models/#select-only-particular-account-models)。 `codexAccountNamespaces` 映射为空时,账户限定的选择器行处于关闭状态。非空映射中省略 `codexAccountPickerEnabled` 时,为保持向后兼容会视为已启用。设为 `false` 会隐藏生成的账户限定行并恢复选择器中的裸原生行,但不会删除映射,也不会禁用精确的 `/` 路由。 @@ -66,7 +68,7 @@ visibility = "list" | 路由 | 选择器 id 与目录元数据 | | --- | --- | | Codex 登录(账户限定的选择器行未启用) | 显示 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 等裸原生 id,并按 `codexAccountMode` 使用 Pool 或 Direct。GPT-5.6 行使用 922,000-token 目录窗口。 | -| Codex 登录(账户限定的选择器行已启用且存在有效 selector) | 为每个有效 selector 与受支持原生模型的组合显示 `/` 行。每行只使用映射账户,裸原生行会从选择器中隐藏。原生 metadata 与 context window 会保留。 | +| Codex 登录(账户限定的选择器行已启用且存在有效 selector) | 省略 `codexAccountPickerModels` 时,每个有效 selector 都会获得所有受支持原生模型的行,裸原生行会被隐藏。提供该映射时,公共池原生模型仍以裸行显示,只有所选的模型和 selector 组合会添加限定行。每个限定行只使用映射账户。原生 metadata 与 context window 会保留。 | | OpenAI(API key) | 恰好八个命名空间行:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna,以及三个 `*-pro` 虚拟 id(八个条目均为 1,050,000 context / 922,000 max input) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna`(922,000) | | Cursor | 静态回退包含 `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna`(1,000,000),以及 Grok 4.5/4.6 的普通和 Fast 条目(500,000)。4.6 还提供 `xhigh`;实时账户发现会决定最终哪些条目仍然可见。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md b/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md index 260ab7ad36..d4fe2abfb1 100644 --- a/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-tw/guides/codex-app-models.md @@ -10,12 +10,16 @@ Codex 的 app-server 會讀取這份共享狀態,但部分 Codex Desktop 版 OpenAI 條目使用兩條憑證路線:原生 Codex 登入,以及帶名稱空間的 `openai-apikey/` API key 傳輸。僅在 Pool 與 Direct 之間切換 `codexAccountMode` 本身不會改變選擇器 id。不過, 當 `codexAccountPickerEnabled` 啟用帳號限定選擇器列,且 `codexAccountNamespaces` 中仍有對應 -帳號存在的合格選擇器時,opencodex 會為這些對應帳號新增獨立的 `/` -列,並從 Codex 選擇器中隱藏裸的原生列。選擇器標籤是使用者自訂的公開名稱,本身沒有帳號角色 +帳號存在的合格選擇器時,結果取決於 `codexAccountPickerModels`。省略這份對應表時,系統沿用舊行為: +為每個受支援模型新增獨立的 `/` 列,並從 Codex 選擇器中隱藏裸的 +原生列。提供這份對應表時,共用池原生模型仍以裸列顯示,只有選取的模型與帳號組合會新增限定列。 +選擇器標籤是使用者自訂的公開名稱,本身沒有帳號角色 的語意。選擇限定列只會使用其對應的帳號,不會改變目前 Pool 帳號;當目標不可用時會失敗關閉 (fails closed),而不是切換帳號。參見 [精確 Codex 帳號選擇器](/zh-tw/reference/configuration/routing/#精確-codex-帳號選擇器)。 +啟用、選擇並儲存模型及恢復原有模式的步驟,請參閱[英文設定說明](/guides/codex-app-models/#select-only-particular-account-models)。 + 當 `codexAccountNamespaces` 對應表為空時,帳號限定選擇器列是關閉的。若省略 `codexAccountPickerEnabled` 但對應表非空,基於向後相容會被視為啟用。將其設為 `false` 可以 隱藏生成的限定列並恢復選擇器中的裸原生列,同時不必刪除對應或停用精確的 @@ -88,7 +92,7 @@ GPT-5.6,以便提供每個模型真實的身份和後設資料,而不是套 | 路由 | 選擇器 id 與目錄後設資料 | | --- | --- | | Codex 登入(停用帳號限定列) | 裸原生 id,例如 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna`;透過 `codexAccountMode` 選擇 Pool 或 Direct。GPT-5.6 列使用 922,000 token 的目錄視窗。 | -| Codex 登入(啟用帳號限定列且有合格選擇器) | 每個合格選擇器與受支援的原生模型各有一列 `/`;每列只使用其對應帳號,且裸原生列會從選擇器中隱藏。原生後設資料與 context 視窗保持不變。 | +| Codex 登入(啟用帳號限定列且有合格選擇器) | 省略 `codexAccountPickerModels` 時,每個合格選擇器都會取得所有受支援原生模型的列,裸原生列會被隱藏。提供這份對應表時,共用池原生模型仍以裸列顯示,只有選取的模型與選擇器組合會新增限定列。每個限定列只使用其對應帳號。原生後設資料與 context 視窗保持不變。 | | OpenAI(API key) | 恰好八個帶名稱空間的列:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna 與三個 `*-pro` 虛擬 id(全部八個都是 1,050,000 context;922,000 max input) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna`(922,000) | | Cursor | 靜態回退目錄包含 `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna`(1,000,000),以及 Grok 4.5/4.6 的一般與 Fast 項目(500,000)。4.6 還提供 `xhigh`;帳號的即時發現結果決定最終顯示哪些模型。 | diff --git a/gui/src/components/CodexAccountPickerSetting.tsx b/gui/src/components/CodexAccountPickerSetting.tsx index 4988b715f3..8224def9eb 100644 --- a/gui/src/components/CodexAccountPickerSetting.tsx +++ b/gui/src/components/CodexAccountPickerSetting.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { readJsonOrThrow } from "../fetch-json"; import { startVisibilityPoll } from "../visibility-poll"; import { createBoundedFetch } from "../bounded-fetch"; @@ -6,6 +6,54 @@ import { useT } from "../i18n/shared"; import type { NoticeTone } from "../ui"; type Feedback = { tone: NoticeTone; message: string } | null; +type ModelsMap = Record; +type AccountModelOption = { selector: string; models: string[] }; +type AccountFieldsPayload = { codexAccountPickerModels?: unknown; codexAccountPickerOptions?: unknown }; + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every(item => typeof item === "string"); +} + +function isModelsMap(value: unknown): value is ModelsMap { + return !!value && typeof value === "object" && !Array.isArray(value) + && Object.values(value as Record).every(isStringArray); +} + +function isAccountOptions(value: unknown): value is AccountModelOption[] { + return Array.isArray(value) && value.every(entry => !!entry && typeof entry === "object" + && typeof (entry as { selector?: unknown }).selector === "string" + && isStringArray((entry as { models?: unknown }).models)); +} + +/** Order-insensitive equality for the per-account model draft vs. the last confirmed map. */ +function sameModelsMap(a: ModelsMap, b: ModelsMap): boolean { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + const av = a[key] ?? []; + const bv = b[key]; + if (!bv || av.length !== bv.length) return false; + const bset = new Set(bv); + if (!av.every(model => bset.has(model))) return false; + } + return true; +} + +/** Prune unavailable models and removed selectors before saving or restoring customization. */ +function pruneDraftToOptions(map: ModelsMap, currentOptions: AccountModelOption[]): ModelsMap { + const bySelector = new Map(currentOptions.map(option => [option.selector, new Set(option.models)])); + let changed = false; + const next: ModelsMap = {}; + for (const [selector, models] of Object.entries(map)) { + const available = bySelector.get(selector); + if (!available) { changed = true; continue; } + const kept = models.filter(model => available.has(model)); + if (kept.length !== models.length) changed = true; + next[selector] = kept; + } + return changed ? next : map; +} /** Opt-in control for account-qualified Codex model-picker entries. */ export default function CodexAccountPickerSetting({ apiBase }: { apiBase: string }) { @@ -19,28 +67,82 @@ export default function CodexAccountPickerSetting({ apiBase }: { apiBase: string const savingRef = useRef(false); const loadGenerationRef = useRef(0); + // Second, independent opt-in: restrict which models show under each account selector. + // Backend-gated (older /api/settings responses omit codexAccountPickerOptions). The main + // enable switch above never reads or writes codexAccountPickerModels -- flipping it on/off + // must not touch this field at all. With customize OFF (the default), enabling the picker + // keeps the exact legacy behavior: account-qualified entries fully replace the plain rows + // for those accounts. Turning customize ON does not change that replacement; it only narrows + // which models appear under each account entry, while common pool models keep appearing + // alongside those account entries instead of being hidden by them. + const [customizeSupported, setCustomizeSupported] = useState(false); + const [customize, setCustomize] = useState(false); + const [customizeSaving, setCustomizeSaving] = useState(false); + const [options, setOptions] = useState([]); + const [savedModels, setSavedModels] = useState({}); + const [draft, setDraft] = useState({}); + const [modelsSaving, setModelsSaving] = useState(false); + const [modelsFeedback, setModelsFeedback] = useState(null); + const [query, setQuery] = useState>({}); + const customizeRef = useRef(false); + const customizeSavingRef = useRef(false); + const modelsSavingRef = useRef(false); + const dirty = useMemo(() => !sameModelsMap(draft, savedModels), [draft, savedModels]); + const dirtyRef = useRef(false); + useEffect(() => { dirtyRef.current = dirty; }, [dirty]); + // Any in-flight mutation blocks every other mutating control, so a customize toggle cannot + // race a model-list save (or vice versa) and leave the confirmed state ambiguous. + const busy = saving || customizeSaving || modelsSaving; + const anyMutationInFlight = useCallback(() => savingRef.current || customizeSavingRef.current || modelsSavingRef.current, []); + + // Applies the account-selector fields from any /api/settings response (GET or PUT). Live + // selector labels are only (re)initialized on the enable-picker PUT and the customize/save + // PUTs, not on a plain GET, so this runs after every one of those responses too, not only + // after the background poll. + const applyAccountFields = useCallback((payload: AccountFieldsPayload) => { + const rawModels = payload.codexAccountPickerModels; + const rawOptions = payload.codexAccountPickerOptions; + const supported = isAccountOptions(rawOptions) && (rawModels === null || isModelsMap(rawModels)); + setCustomizeSupported(supported); + if (!supported) return; + setOptions(rawOptions as AccountModelOption[]); + if (dirtyRef.current) return; // never clobber an unsaved draft mid-edit. + const models = rawModels as ModelsMap | null; + customizeRef.current = models !== null; + setCustomize(models !== null); + if (models !== null) { + setSavedModels(models); + setDraft(models); + } + }, []); + const load = useCallback(async () => { - if (savingRef.current) return; + if (anyMutationInFlight() || dirtyRef.current) return; const generation = ++loadGenerationRef.current; const bounded = createBoundedFetch(15_000); try { const response = await fetch(`${apiBase}/api/settings`, { signal: bounded.signal }); if (!response.ok) throw new Error("load"); - const payload = await response.json() as { codexAccountPickerEnabled?: unknown }; - if (savingRef.current || generation !== loadGenerationRef.current) return; + const payload = await response.json() as { + codexAccountPickerEnabled?: unknown; + codexAccountPickerModels?: unknown; + codexAccountPickerOptions?: unknown; + }; + if (anyMutationInFlight() || dirtyRef.current || generation !== loadGenerationRef.current) return; if (typeof payload.codexAccountPickerEnabled !== "boolean") throw new Error("shape"); enabledRef.current = payload.codexAccountPickerEnabled; setEnabled(payload.codexAccountPickerEnabled); + applyAccountFields(payload); setHydrated(true); setLoadError(false); } catch { - if (!savingRef.current && generation === loadGenerationRef.current) { + if (!anyMutationInFlight() && !dirtyRef.current && generation === loadGenerationRef.current) { setLoadError(true); } } finally { bounded.clear(); } - }, [apiBase]); + }, [apiBase, applyAccountFields, anyMutationInFlight]); useEffect(() => { const timeout = window.setTimeout(() => { void load(); }, 0); @@ -52,7 +154,7 @@ export default function CodexAccountPickerSetting({ apiBase }: { apiBase: string }, [load]); const toggle = useCallback(async () => { - if (savingRef.current || !hydrated) return; + if (anyMutationInFlight() || !hydrated) return; const previous = enabledRef.current; const requested = !previous; enabledRef.current = requested; @@ -70,6 +172,8 @@ export default function CodexAccountPickerSetting({ apiBase }: { apiBase: string const payload = (await readJsonOrThrow<{ ok?: unknown; codexAccountPickerEnabled?: unknown; + codexAccountPickerModels?: unknown; + codexAccountPickerOptions?: unknown; catalogRefreshPending?: unknown; }>(response)) ?? {}; if (payload.ok !== true || typeof payload.codexAccountPickerEnabled !== "boolean") { @@ -77,6 +181,7 @@ export default function CodexAccountPickerSetting({ apiBase }: { apiBase: string } enabledRef.current = payload.codexAccountPickerEnabled; setEnabled(payload.codexAccountPickerEnabled); + applyAccountFields(payload); setHydrated(true); setLoadError(false); setFeedback(payload.catalogRefreshPending === true @@ -90,67 +195,274 @@ export default function CodexAccountPickerSetting({ apiBase }: { apiBase: string savingRef.current = false; setSaving(false); } - }, [apiBase, hydrated, t]); + }, [apiBase, hydrated, applyAccountFields, anyMutationInFlight, t]); + + const toggleCustomize = useCallback(async () => { + if (anyMutationInFlight() || !hydrated || !customizeSupported) return; + const previous = customizeRef.current; + const requested = !previous; + const nextModels: ModelsMap | null = requested ? pruneDraftToOptions(draft, options) : null; + customizeRef.current = requested; + setCustomize(requested); + customizeSavingRef.current = true; + setCustomizeSaving(true); + setModelsFeedback(null); + loadGenerationRef.current += 1; + try { + const response = await fetch(`${apiBase}/api/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ codexAccountPickerModels: nextModels }), + }); + const payload = (await readJsonOrThrow<{ + ok?: unknown; + codexAccountPickerModels?: unknown; + codexAccountPickerOptions?: unknown; + catalogRefreshPending?: unknown; + }>(response)) ?? {}; + const confirmed = payload.codexAccountPickerModels; + // The server always returns this field on a settings PUT; a missing field is treated as + // any other malformed response instead of being silently coerced to null. + if (payload.ok !== true || !(confirmed === null || isModelsMap(confirmed))) { + throw new Error("unconfirmed"); + } + const isOn = confirmed !== null; + customizeRef.current = isOn; + setCustomize(isOn); + if (isOn) { + setSavedModels(confirmed); + setDraft(confirmed); + } + if (isAccountOptions(payload.codexAccountPickerOptions)) { + setOptions(payload.codexAccountPickerOptions); + } + setModelsFeedback(payload.catalogRefreshPending === true + ? { tone: "warn", message: t("codexAuth.catalogRefreshPending") } + : { tone: "ok", message: t("codexAuth.accountPickerCustomizeUpdated") }); + } catch { + customizeRef.current = previous; + setCustomize(previous); + setModelsFeedback({ tone: "err", message: t("codexAuth.accountPickerCustomizeUpdateFailed") }); + } finally { + customizeSavingRef.current = false; + setCustomizeSaving(false); + } + }, [apiBase, hydrated, customizeSupported, draft, options, anyMutationInFlight, t]); + + const toggleModel = useCallback((selector: string, model: string) => { + setDraft(prev => { + const current = prev[selector] ?? []; + const has = current.includes(model); + const nextList = has ? current.filter(m => m !== model) : [...current, model]; + return { ...prev, [selector]: nextList }; + }); + }, []); + + const saveModels = useCallback(async () => { + if (anyMutationInFlight() || !customize || !dirty) return; + const nextModels = pruneDraftToOptions(draft, options); + modelsSavingRef.current = true; + loadGenerationRef.current += 1; + setModelsSaving(true); + setModelsFeedback(null); + try { + const response = await fetch(`${apiBase}/api/settings`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ codexAccountPickerModels: nextModels }), + }); + const payload = (await readJsonOrThrow<{ + ok?: unknown; + codexAccountPickerModels?: unknown; + codexAccountPickerOptions?: unknown; + catalogRefreshPending?: unknown; + }>(response)) ?? {}; + const confirmed = payload.codexAccountPickerModels; + if (payload.ok !== true || !isModelsMap(confirmed)) throw new Error("unconfirmed"); + setSavedModels(confirmed); + setDraft(confirmed); + if (isAccountOptions(payload.codexAccountPickerOptions)) { + setOptions(payload.codexAccountPickerOptions); + } + setModelsFeedback(payload.catalogRefreshPending === true + ? { tone: "warn", message: t("codexAuth.catalogRefreshPending") } + : { tone: "ok", message: t("codexAuth.accountPickerModelsSaved") }); + } catch { + setModelsFeedback({ tone: "err", message: t("codexAuth.accountPickerModelsSaveFailed") }); + } finally { + modelsSavingRef.current = false; + setModelsSaving(false); + } + }, [apiBase, customize, dirty, draft, options, anyMutationInFlight, t]); const initialLoadFailed = loadError && !hydrated; return ( -
-
- {t("codexAuth.accountPickerTitle")} -
- {initialLoadFailed - ? t("codexAuth.accountPickerLoadFailed") - : !hydrated - ? t("common.loading") - : t(enabled - ? "codexAuth.accountPickerOnDesc" - : "codexAuth.accountPickerOffDesc")} -
- {hydrated && enabled && ( -
{t("codexAuth.accountPickerCompatibility")}
- )} - {hydrated && loadError && ( -
- {t("codexAuth.accountPickerRefreshFailed")} +
+
+
+ {t("codexAuth.accountPickerTitle")} +
+ {initialLoadFailed + ? t("codexAuth.accountPickerLoadFailed") + : !hydrated + ? t("common.loading") + : t(!enabled + ? "codexAuth.accountPickerOffDesc" + : customize + ? "codexAuth.accountPickerOnDescCustomized" + : "codexAuth.accountPickerOnDesc")}
- )} -
-
- {loadError && ( - - )} - {hydrated && ( -
+
+ {loadError && ( + + )} + {hydrated && ( + + )} +
+ {feedback && ( +
- - + {feedback.message} +
)}
- {feedback && ( + + {hydrated && enabled && customizeSupported && (
- {feedback.message} +
+ {t("codexAuth.accountPickerCustomizeTitle")} +
+ {t(customize + ? "codexAuth.accountPickerCustomizeOnDesc" + : "codexAuth.accountPickerCustomizeOffDesc")} +
+
+
+ +
+ {modelsFeedback && ( +
+ {modelsFeedback.message} +
+ )} +
+ )} + + {hydrated && enabled && customizeSupported && customize && ( +
+
+ {t("codexAuth.accountPickerModelsCommonNote")} +
+ {options.length === 0 && ( +
{t("codexAuth.accountPickerModelsNoAccounts")}
+ )} + {options.map(option => { + const selected = new Set(draft[option.selector] ?? []); + const rowQuery = query[option.selector] ?? ""; + const filtered = rowQuery + ? option.models.filter(model => model.toLowerCase().includes(rowQuery.toLowerCase())) + : option.models; + return ( +
+
+ {option.selector} + + {t("codexAuth.accountPickerModelsSelectedCount", { n: selected.size })} + +
+ setQuery(prev => ({ ...prev, [option.selector]: e.target.value }))} + placeholder={t("codexAuth.accountPickerModelsSearchPlaceholder")} + aria-label={t("codexAuth.accountPickerModelsSearchPlaceholder")} + disabled={busy} + /> +
+ {filtered.length === 0 && ( +
{t("codexAuth.accountPickerModelsNoMatch")}
+ )} + {filtered.map(model => ( + + ))} +
+
+ ); + })} +
+ +
)}
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 68363152b5..f2f910d0b5 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1387,6 +1387,19 @@ export const de: Record = { "codexAuth.accountPickerUpdateFailed": "Die Kontozielauswahl konnte nicht aktualisiert werden. Die zuletzt bestätigte Einstellung wird angezeigt.", "codexAuth.accountPickerLoadFailed": "Die Einstellung für die Kontozielauswahl konnte nicht geladen werden.", "codexAuth.accountPickerRefreshFailed": "Diese Einstellung konnte nicht aktualisiert werden. Der zuletzt bestätigte Wert wird weiterhin angezeigt.", + "codexAuth.accountPickerCustomizeTitle": "Modelle je Konto anpassen", + "codexAuth.accountPickerCustomizeOffDesc": "Jeder Kontoselektor zeigt derzeit alle verfügbaren Modelle, genau wie vor dieser Einstellung.", + "codexAuth.accountPickerCustomizeOnDesc": "Wähle unten aus, welche Modelle unter jedem Kontoselektor erscheinen. Modelle aus dem gemeinsamen Pool bleiben überall sichtbar.", + "codexAuth.accountPickerCustomizeUpdated": "Modellanpassung je Konto aktualisiert.", + "codexAuth.accountPickerCustomizeUpdateFailed": "Die Modellanpassung je Konto konnte nicht aktualisiert werden. Die zuletzt bestätigte Einstellung wird angezeigt.", + "codexAuth.accountPickerModelsCommonNote": "Modelle aus dem gemeinsamen Pool bleiben in der Auswahl sichtbar, unabhängig von der Auswahl unten.", + "codexAuth.accountPickerModelsSearchPlaceholder": "Modelle suchen…", + "codexAuth.accountPickerModelsNoMatch": "Keine Modelle entsprechen deiner Suche.", + "codexAuth.accountPickerModelsSelectedCount": "{n} ausgewählt", + "codexAuth.accountPickerModelsSaved": "Modellauswahl gespeichert.", + "codexAuth.accountPickerModelsSaveFailed": "Die Modellauswahl konnte nicht gespeichert werden. Bitte erneut versuchen.", + "codexAuth.accountPickerModelsNoAccounts": "Noch keine Kontoselektoren verfügbar.", + "codexAuth.accountPickerOnDescCustomized": "Jeder Selektor ist eine öffentliche Bezeichnung für ein gespeichertes Konto. Seine Auswahl bindet die Unterhaltung an dieses Konto: keine Pool-Rotation, kein Fallback und keine Änderung des aktiven Pool-Kontos. Modelle aus dem gemeinsamen Pool erscheinen weiterhin neben deinen unten ausgewählten kontospezifischen Modellen.", "codexAuth.advancedSettings": "Erweiterte Einstellungen", "codexAuth.advancedSettingsAria": "Erweiterte Codex-Auth-Einstellungen ein- oder ausblenden", "codexAuth.catalogRefreshPending": "Die Änderung wurde gespeichert, aber die Aktualisierung des Codex-Modellkatalogs steht noch aus. Führe ocx sync aus, um es erneut zu versuchen.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 89fc5ec0d7..3cfaa2ceab 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1959,6 +1959,19 @@ export const en = { "codexAuth.accountPickerUpdateFailed": "Could not update account targeting. The last confirmed setting is shown.", "codexAuth.accountPickerLoadFailed": "Could not load the account-targeting setting.", "codexAuth.accountPickerRefreshFailed": "Could not refresh this setting. The last confirmed value is still shown.", + "codexAuth.accountPickerCustomizeTitle": "Customize models per account", + "codexAuth.accountPickerCustomizeOffDesc": "Every account selector currently shows every available model, the same as before this setting existed.", + "codexAuth.accountPickerCustomizeOnDesc": "Choose which models appear under each account selector below. Common pool models keep showing everywhere.", + "codexAuth.accountPickerCustomizeUpdated": "Per-account model customization updated.", + "codexAuth.accountPickerCustomizeUpdateFailed": "Could not update per-account model customization. The last confirmed setting is shown.", + "codexAuth.accountPickerModelsCommonNote": "Common pool models remain visible in the picker regardless of the selections below.", + "codexAuth.accountPickerModelsSearchPlaceholder": "Search models…", + "codexAuth.accountPickerModelsNoMatch": "No models match your search.", + "codexAuth.accountPickerModelsSelectedCount": "{n} selected", + "codexAuth.accountPickerModelsSaved": "Model selection saved.", + "codexAuth.accountPickerModelsSaveFailed": "Could not save the model selection. Try again.", + "codexAuth.accountPickerModelsNoAccounts": "No account selectors are available yet.", + "codexAuth.accountPickerOnDescCustomized": "Each selector is a public label for one stored account. Choosing it locks that conversation to the mapped account: it never rotates or falls back, and it does not change the active Pool account. Common pool models keep appearing alongside your selected per-account models below.", "codexAuth.advancedSettings": "Advanced settings", "codexAuth.advancedSettingsAria": "Show or hide advanced Codex Auth settings", "codexAuth.catalogRefreshPending": "The change was saved, but the Codex model catalog refresh is pending. Run ocx sync to retry.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 4cf6818072..74e67e73c0 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1891,6 +1891,19 @@ export const fr: Record = { "codexAuth.accountPickerUpdateFailed": "Impossible de mettre à jour le ciblage des comptes. Le dernier paramètre confirmé est affiché.", "codexAuth.accountPickerLoadFailed": "Impossible de charger le paramètre de ciblage des comptes.", "codexAuth.accountPickerRefreshFailed": "Impossible d’actualiser ce paramètre. La dernière valeur confirmée reste affichée.", + "codexAuth.accountPickerCustomizeTitle": "Personnaliser les modèles par compte", + "codexAuth.accountPickerCustomizeOffDesc": "Chaque sélecteur de compte affiche actuellement tous les modèles disponibles, comme avant l’existence de ce paramètre.", + "codexAuth.accountPickerCustomizeOnDesc": "Choisissez ci-dessous les modèles qui apparaissent sous chaque sélecteur de compte. Les modèles du groupe commun restent visibles partout.", + "codexAuth.accountPickerCustomizeUpdated": "Personnalisation des modèles par compte mise à jour.", + "codexAuth.accountPickerCustomizeUpdateFailed": "Impossible de mettre à jour la personnalisation des modèles par compte. Le dernier paramètre confirmé est affiché.", + "codexAuth.accountPickerModelsCommonNote": "Les modèles du groupe commun restent visibles dans le sélecteur, quelle que soit la sélection ci-dessous.", + "codexAuth.accountPickerModelsSearchPlaceholder": "Rechercher des modèles…", + "codexAuth.accountPickerModelsNoMatch": "Aucun modèle ne correspond à votre recherche.", + "codexAuth.accountPickerModelsSelectedCount": "{n} sélectionné(s)", + "codexAuth.accountPickerModelsSaved": "Sélection de modèles enregistrée.", + "codexAuth.accountPickerModelsSaveFailed": "Impossible d’enregistrer la sélection de modèles. Réessayez.", + "codexAuth.accountPickerModelsNoAccounts": "Aucun sélecteur de compte n’est encore disponible.", + "codexAuth.accountPickerOnDescCustomized": "Chaque sélecteur est un libellé public associé à un compte stocké. Le choisir verrouille la conversation sur le compte correspondant : elle n’alterne jamais et ne bascule pas vers un compte de secours, sans modifier le compte Groupe actif. Les modèles du groupe commun continuent d’apparaître à côté des modèles par compte sélectionnés ci-dessous.", "codexAuth.advancedSettings": "Paramètres avancés", "codexAuth.advancedSettingsAria": "Afficher ou masquer les paramètres avancés de l’authentification Codex", "codexAuth.catalogRefreshPending": "La modification a été enregistrée, mais l’actualisation du catalogue de modèles Codex est en attente. Exécutez ocx sync pour réessayer.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c77edbe2c7..d00b1c8d97 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1817,6 +1817,19 @@ export const ja: Record = { "codexAuth.accountPickerUpdateFailed": "アカウント指定を更新できませんでした。最後に確認された設定を表示しています。", "codexAuth.accountPickerLoadFailed": "アカウント指定の設定を読み込めませんでした。", "codexAuth.accountPickerRefreshFailed": "この設定を更新できませんでした。最後に確認された値を引き続き表示しています。", + "codexAuth.accountPickerCustomizeTitle": "アカウントごとにモデルをカスタマイズ", + "codexAuth.accountPickerCustomizeOffDesc": "各アカウントセレクターには、この設定が導入される前と同様に、利用可能なすべてのモデルが表示されます。", + "codexAuth.accountPickerCustomizeOnDesc": "以下で各アカウントセレクターに表示するモデルを選択できます。共通プールのモデルは引き続きどこでも表示されます。", + "codexAuth.accountPickerCustomizeUpdated": "アカウントごとのモデルカスタマイズを更新しました。", + "codexAuth.accountPickerCustomizeUpdateFailed": "アカウントごとのモデルカスタマイズを更新できませんでした。最後に確認された設定を表示しています。", + "codexAuth.accountPickerModelsCommonNote": "以下の選択に関わらず、共通プールのモデルはピッカーに表示され続けます。", + "codexAuth.accountPickerModelsSearchPlaceholder": "モデルを検索…", + "codexAuth.accountPickerModelsNoMatch": "検索条件に一致するモデルがありません。", + "codexAuth.accountPickerModelsSelectedCount": "{n} 件選択中", + "codexAuth.accountPickerModelsSaved": "モデルの選択を保存しました。", + "codexAuth.accountPickerModelsSaveFailed": "モデルの選択を保存できませんでした。もう一度お試しください。", + "codexAuth.accountPickerModelsNoAccounts": "利用できるアカウントセレクターはまだありません。", + "codexAuth.accountPickerOnDescCustomized": "各セレクターは保存済みアカウント1つに対応する公開ラベルです。選択した会話はそのアカウントに固定され、Poolのローテーションやフォールバックは行われず、現在のPoolアカウントも変更されません。共通プールのモデルは、以下で選択したアカウントごとのモデルと並んで引き続き表示されます。", "codexAuth.advancedSettings": "詳細設定", "codexAuth.advancedSettingsAria": "高度な Codex 認証設定を表示または非表示", "codexAuth.catalogRefreshPending": "変更は保存されましたが、Codex モデルカタログの更新が保留中です。ocx sync を実行して再試行してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 34d5ceae87..12837b348e 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1423,6 +1423,19 @@ export const ko: Record = { "codexAuth.accountPickerUpdateFailed": "계정 지정 설정을 업데이트하지 못했습니다. 마지막으로 확인된 설정을 표시합니다.", "codexAuth.accountPickerLoadFailed": "계정 지정 설정을 불러오지 못했습니다.", "codexAuth.accountPickerRefreshFailed": "이 설정을 새로 고치지 못했습니다. 마지막으로 확인된 값을 계속 표시합니다.", + "codexAuth.accountPickerCustomizeTitle": "계정별 세부 설정", + "codexAuth.accountPickerCustomizeOffDesc": "이 설정이 생기기 전과 마찬가지로 모든 계정 선택기에 사용 가능한 모든 모델이 표시됩니다.", + "codexAuth.accountPickerCustomizeOnDesc": "아래에서 각 계정 선택기에 표시할 모델을 선택하세요. 공통 풀 모델은 계속 모든 곳에 표시됩니다.", + "codexAuth.accountPickerCustomizeUpdated": "계정별 모델 사용자 지정을 업데이트했습니다.", + "codexAuth.accountPickerCustomizeUpdateFailed": "계정별 모델 사용자 지정을 업데이트하지 못했습니다. 마지막으로 확인된 설정을 표시합니다.", + "codexAuth.accountPickerModelsCommonNote": "아래 선택과 관계없이 공통 풀 모델은 선택기에 계속 표시됩니다.", + "codexAuth.accountPickerModelsSearchPlaceholder": "모델 검색…", + "codexAuth.accountPickerModelsNoMatch": "검색과 일치하는 모델이 없습니다.", + "codexAuth.accountPickerModelsSelectedCount": "{n}개 선택됨", + "codexAuth.accountPickerModelsSaved": "모델 선택을 저장했습니다.", + "codexAuth.accountPickerModelsSaveFailed": "모델 선택을 저장하지 못했습니다. 다시 시도하세요.", + "codexAuth.accountPickerModelsNoAccounts": "아직 사용할 수 있는 계정 선택기가 없습니다.", + "codexAuth.accountPickerOnDescCustomized": "각 선택기는 저장된 계정 하나를 나타내는 공개 레이블입니다. 선택하면 해당 대화가 그 계정에 고정되며 Pool 순환이나 대체가 일어나지 않고 활성 Pool 계정도 바뀌지 않습니다. 공통 풀 모델은 아래에서 선택한 계정별 모델과 함께 계속 표시됩니다.", "codexAuth.advancedSettings": "고급 설정", "codexAuth.advancedSettingsAria": "고급 Codex 인증 설정 표시 또는 숨기기", "codexAuth.catalogRefreshPending": "변경 사항은 저장되었지만 Codex 모델 카탈로그 새로 고침이 보류 중입니다. ocx sync를 실행해 다시 시도하세요.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 488f87d55b..de617e2563 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1887,6 +1887,19 @@ export const ru: Record = { "codexAuth.accountPickerUpdateFailed": "Не удалось обновить выбор целевого аккаунта. Показана последняя подтверждённая настройка.", "codexAuth.accountPickerLoadFailed": "Не удалось загрузить настройку выбора аккаунта.", "codexAuth.accountPickerRefreshFailed": "Не удалось обновить эту настройку. По-прежнему показано последнее подтверждённое значение.", + "codexAuth.accountPickerCustomizeTitle": "Настроить модели для каждого аккаунта", + "codexAuth.accountPickerCustomizeOffDesc": "Сейчас каждый селектор аккаунта показывает все доступные модели, как и до появления этой настройки.", + "codexAuth.accountPickerCustomizeOnDesc": "Выберите ниже, какие модели показывать под каждым селектором аккаунта. Модели общего пула по-прежнему видны в списке моделей.", + "codexAuth.accountPickerCustomizeUpdated": "Настройка моделей для каждого аккаунта обновлена.", + "codexAuth.accountPickerCustomizeUpdateFailed": "Не удалось обновить настройку моделей для каждого аккаунта. Показана последняя подтверждённая настройка.", + "codexAuth.accountPickerModelsCommonNote": "Независимо от выбора ниже, модели общего пула остаются видны в списке.", + "codexAuth.accountPickerModelsSearchPlaceholder": "Поиск моделей…", + "codexAuth.accountPickerModelsNoMatch": "Нет моделей, соответствующих поиску.", + "codexAuth.accountPickerModelsSelectedCount": "Выбрано: {n}", + "codexAuth.accountPickerModelsSaved": "Выбор моделей сохранён.", + "codexAuth.accountPickerModelsSaveFailed": "Не удалось сохранить выбор моделей. Попробуйте снова.", + "codexAuth.accountPickerModelsNoAccounts": "Пока нет доступных селекторов аккаунтов.", + "codexAuth.accountPickerOnDescCustomized": "Каждый селектор — публичная метка одного сохранённого аккаунта. Выбор закрепляет разговор за этим аккаунтом: без ротации и перехода на другой аккаунт, а активный аккаунт Pool не меняется. Модели общего пула по-прежнему отображаются рядом с выбранными ниже моделями для конкретного аккаунта.", "codexAuth.advancedSettings": "Дополнительные настройки", "codexAuth.advancedSettingsAria": "Показать или скрыть дополнительные настройки Codex Auth", "codexAuth.catalogRefreshPending": "Изменение сохранено, но обновление каталога моделей Codex ещё не завершено. Выполните ocx sync, чтобы повторить попытку.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 2e46e2792f..595fcd83c6 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1806,6 +1806,19 @@ export const tr: Record = { "codexAuth.accountPickerUpdateFailed": "Hesap hedefleme güncellenemedi. Son doğrulanan ayar gösteriliyor.", "codexAuth.accountPickerLoadFailed": "Hesap hedefleme ayarı yüklenemedi.", "codexAuth.accountPickerRefreshFailed": "Bu ayar yenilenemedi. Son doğrulanan değer hâlâ gösteriliyor.", + "codexAuth.accountPickerCustomizeTitle": "Hesap başına modelleri özelleştir", + "codexAuth.accountPickerCustomizeOffDesc": "Her hesap seçicisi şu anda, bu ayar var olmadan önceki gibi, kullanılabilir tüm modelleri gösterir.", + "codexAuth.accountPickerCustomizeOnDesc": "Aşağıda her hesap seçicisi altında hangi modellerin görüneceğini seçin. Ortak havuz modelleri her yerde görünmeye devam eder.", + "codexAuth.accountPickerCustomizeUpdated": "Hesap başına model özelleştirmesi güncellendi.", + "codexAuth.accountPickerCustomizeUpdateFailed": "Hesap başına model özelleştirmesi güncellenemedi. Son doğrulanan ayar gösteriliyor.", + "codexAuth.accountPickerModelsCommonNote": "Aşağıdaki seçimden bağımsız olarak ortak havuz modelleri seçicide görünmeye devam eder.", + "codexAuth.accountPickerModelsSearchPlaceholder": "Modelleri ara…", + "codexAuth.accountPickerModelsNoMatch": "Aramanızla eşleşen model yok.", + "codexAuth.accountPickerModelsSelectedCount": "{n} seçildi", + "codexAuth.accountPickerModelsSaved": "Model seçimi kaydedildi.", + "codexAuth.accountPickerModelsSaveFailed": "Model seçimi kaydedilemedi. Tekrar deneyin.", + "codexAuth.accountPickerModelsNoAccounts": "Henüz kullanılabilir hesap seçicisi yok.", + "codexAuth.accountPickerOnDescCustomized": "Her seçici, kayıtlı bir hesap için genel bir etikettir. Bunu seçmek, konuşmayı eşlenen hesaba kilitler: asla dönmez veya yedek hesaba geçmez ve aktif Havuz hesabını değiştirmez. Ortak havuz modelleri, aşağıda seçtiğiniz hesaba özgü modellerin yanında görünmeye devam eder.", "codexAuth.advancedSettings": "Gelişmiş ayarlar", "codexAuth.advancedSettingsAria": "Gelişmiş Codex Auth ayarlarını göster veya gizle", "codexAuth.catalogRefreshPending": "Değişiklik kaydedildi ancak Codex model kataloğunun yenilenmesi bekleniyor. Yeniden denemek için ocx sync çalıştırın.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index b9a26da41c..d26ed35d8f 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2390,6 +2390,19 @@ export const zhTW: Record = { "codexAuth.accountPickerUpdateFailed": "無法更新帳號指定設定。目前顯示的是最後一次確認的設定。", "codexAuth.accountPickerLoadFailed": "無法載入帳號指定設定。", "codexAuth.accountPickerRefreshFailed": "無法重新整理此設定。目前仍顯示最後一次確認的值。", + "codexAuth.accountPickerCustomizeTitle": "依帳號自訂模型", + "codexAuth.accountPickerCustomizeOffDesc": "目前每個帳號選擇器都會顯示所有可用模型,與此設定出現之前相同。", + "codexAuth.accountPickerCustomizeOnDesc": "在下方選擇每個帳號選擇器要顯示的模型。共用池模型仍會在所有地方顯示。", + "codexAuth.accountPickerCustomizeUpdated": "已更新依帳號的模型自訂設定。", + "codexAuth.accountPickerCustomizeUpdateFailed": "無法更新依帳號的模型自訂設定。目前顯示的是最後一次確認的設定。", + "codexAuth.accountPickerModelsCommonNote": "無論下方如何選擇,共用池模型都會繼續顯示在選擇器中。", + "codexAuth.accountPickerModelsSearchPlaceholder": "搜尋模型…", + "codexAuth.accountPickerModelsNoMatch": "沒有符合搜尋的模型。", + "codexAuth.accountPickerModelsSelectedCount": "已選擇 {n} 個", + "codexAuth.accountPickerModelsSaved": "模型選擇已儲存。", + "codexAuth.accountPickerModelsSaveFailed": "無法儲存模型選擇,請再試一次。", + "codexAuth.accountPickerModelsNoAccounts": "尚無可用的帳號選擇器。", + "codexAuth.accountPickerOnDescCustomized": "每個選擇器都是某個已儲存帳號的公開標籤。選擇後,該對話會鎖定到對應的帳號:永遠不會輪換或容錯移轉,也不會改變目前 Pool 的帳號。共用池模型仍會與下方選擇的帳號專屬模型一併顯示。", "codexAuth.advancedSettings": "進階設定", "codexAuth.advancedSettingsAria": "顯示或隱藏進階 Codex 認證設定", "codexAuth.catalogRefreshPending": "變更已儲存,但 Codex 模型目錄仍在等待重新整理。請執行 ocx sync 重試。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 46866680b0..43b326a3e1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1404,6 +1404,19 @@ export const zh: Record = { "codexAuth.accountPickerUpdateFailed": "无法更新账号指定设置。当前显示的是最后一次确认的设置。", "codexAuth.accountPickerLoadFailed": "无法加载账号指定设置。", "codexAuth.accountPickerRefreshFailed": "无法刷新此设置。当前仍显示最后一次确认的值。", + "codexAuth.accountPickerCustomizeTitle": "按账号自定义模型", + "codexAuth.accountPickerCustomizeOffDesc": "目前每个账号选择器都会显示所有可用模型,与该设置出现之前一致。", + "codexAuth.accountPickerCustomizeOnDesc": "在下方选择每个账号选择器下要显示的模型。公共池模型仍会在所有地方显示。", + "codexAuth.accountPickerCustomizeUpdated": "按账号的模型自定义设置已更新。", + "codexAuth.accountPickerCustomizeUpdateFailed": "无法更新按账号的模型自定义设置。当前显示的是最后一次确认的设置。", + "codexAuth.accountPickerModelsCommonNote": "无论下方如何选择,公共池模型都会继续显示在选择器中。", + "codexAuth.accountPickerModelsSearchPlaceholder": "搜索模型…", + "codexAuth.accountPickerModelsNoMatch": "没有与搜索匹配的模型。", + "codexAuth.accountPickerModelsSelectedCount": "已选择 {n} 个", + "codexAuth.accountPickerModelsSaved": "模型选择已保存。", + "codexAuth.accountPickerModelsSaveFailed": "无法保存模型选择,请重试。", + "codexAuth.accountPickerModelsNoAccounts": "暂无可用的账号选择器。", + "codexAuth.accountPickerOnDescCustomized": "每个选择器都是一个已存储账号的公开标签。选择后,该对话会锁定到对应账号,不会参与 Pool 轮换或故障转移,也不会更改当前的 Pool 账号。公共池模型仍会与下方选择的账号专属模型一起显示。", "codexAuth.advancedSettings": "高级设置", "codexAuth.advancedSettingsAria": "显示或隐藏高级 Codex 认证设置", "codexAuth.catalogRefreshPending": "更改已保存,但 Codex 模型目录仍待刷新。请运行 ocx sync 重试。", diff --git a/gui/src/styles.css b/gui/src/styles.css index 1a51a2f40e..d488deda96 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1673,6 +1673,40 @@ dialog.modal-overlay::backdrop { .codex-account-picker-feedback.is-ok { color: var(--green); } .codex-account-picker-feedback.is-warn { color: var(--amber); } .codex-account-picker-feedback.is-err { color: var(--red); } + +.codex-account-picker-wrap { display: block; } +.codex-account-picker-customize-card { gap: 16px; flex-wrap: wrap; margin-top: 8px; } +.codex-account-picker-models { + margin-top: 8px; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-md, 8px); + display: flex; + flex-direction: column; + gap: 12px; +} +.codex-account-picker-models-note { padding: 0; } +.codex-account-picker-account-row { display: flex; flex-direction: column; gap: 6px; } +.codex-account-picker-account-row-head { display: flex; align-items: center; gap: 8px; justify-content: space-between; } +.codex-account-picker-search { width: 100%; padding: 4px 8px; font-size: var(--text-label); height: var(--control-sm); } +.codex-account-picker-model-list { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + max-height: 8.5rem; + overflow-y: auto; + padding: 4px 2px; +} +.codex-account-picker-model-option { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--text-label); + cursor: pointer; +} +.codex-account-picker-model-option input[type="checkbox"] { cursor: pointer; } +.codex-account-picker-models-actions { display: flex; justify-content: flex-end; } + /* The toggle now lives in the slot below, which carries the auto margin itself. */ /* Toggle slot: matches the height of the threshold compound so the 20px toggle centres against diff --git a/gui/tests/codex-account-picker-setting.test.tsx b/gui/tests/codex-account-picker-setting.test.tsx index d4590fb672..52509b6182 100644 --- a/gui/tests/codex-account-picker-setting.test.tsx +++ b/gui/tests/codex-account-picker-setting.test.tsx @@ -277,4 +277,377 @@ describe("CodexAccountPickerSetting", () => { }); expect(toggle(host).getAttribute("aria-pressed")).toBe("true"); }); + + function toggles(host: ParentNode): HTMLButtonElement[] { + return Array.from(host.querySelectorAll("button.toggle")); + } + + test("hides the customize section entirely when the backend omits account options", async () => { + const host = await mount((async () => response({ codexAccountPickerEnabled: true })) as typeof fetch); + expect(toggles(host)).toHaveLength(1); + expect(host.querySelector(".codex-account-picker-customize-card")).toBeNull(); + expect(host.querySelector(".codex-account-picker-models")).toBeNull(); + }); + + test("reveals per-account model checkboxes only after the customize toggle is turned on", async () => { + const options = [{ selector: "main", models: ["gpt-5.1", "gpt-5.1-codex"] }]; + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + return response({ + ok: true, + codexAccountPickerModels: {}, + codexAccountPickerOptions: options, + }); + } + return response({ + codexAccountPickerEnabled: true, + codexAccountPickerModels: null, + codexAccountPickerOptions: options, + }); + }) as typeof fetch); + + expect(toggles(host)).toHaveLength(2); + const [mainToggle, customizeToggle] = toggles(host); + expect(mainToggle.getAttribute("aria-pressed")).toBe("true"); + expect(customizeToggle.getAttribute("aria-pressed")).toBe("false"); + expect(host.querySelector(".codex-account-picker-models")).toBeNull(); + + await act(async () => { + customizeToggle.click(); + await flush(); + }); + + expect(toggles(host)[1]?.getAttribute("aria-pressed")).toBe("true"); + const list = host.querySelector(".codex-account-picker-models"); + expect(list).not.toBeNull(); + expect(list?.querySelectorAll('input[type="checkbox"]')).toHaveLength(2); + expect(list?.textContent).toContain("gpt-5.1"); + expect(list?.textContent).toContain("gpt-5.1-codex"); + }); + + test("checking boxes never fetches; an explicit Save sends one batched PUT", async () => { + const options = [{ selector: "main", models: ["gpt-5.1", "gpt-5.1-codex"] }]; + let putCount = 0; + let lastPutBody: unknown = null; + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + putCount += 1; + lastPutBody = init.body ? JSON.parse(String(init.body)) : null; + const body = lastPutBody as { codexAccountPickerModels?: Record | null }; + return response({ + ok: true, + codexAccountPickerModels: body.codexAccountPickerModels ?? {}, + codexAccountPickerOptions: options, + }); + } + return response({ + codexAccountPickerEnabled: true, + codexAccountPickerModels: {}, + codexAccountPickerOptions: options, + }); + }) as typeof fetch); + + expect(toggles(host)[1]?.getAttribute("aria-pressed")).toBe("true"); + const checkboxes = Array.from(host.querySelectorAll('.codex-account-picker-models input[type="checkbox"]')); + expect(checkboxes).toHaveLength(2); + const saveButton = Array.from(host.querySelectorAll("button")).find(b => b.textContent === "Save"); + expect(saveButton?.disabled).toBe(true); + + act(() => { checkboxes[0]?.click(); }); + act(() => { checkboxes[1]?.click(); }); + expect(putCount).toBe(0); + expect(saveButton?.disabled).toBe(false); + + await act(async () => { + saveButton?.click(); + await flush(); + }); + + expect(putCount).toBe(1); + expect(lastPutBody).toEqual({ codexAccountPickerModels: { main: ["gpt-5.1", "gpt-5.1-codex"] } }); + expect(saveButton?.disabled).toBe(true); + }); + + test("turning customize off sends null and restores the prior draft if turned back on without reload", async () => { + const options = [{ selector: "main", models: ["gpt-5.1", "gpt-5.1-codex"] }]; + const puts: unknown[] = []; + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as { codexAccountPickerModels?: Record | null }; + puts.push(body.codexAccountPickerModels); + return response({ + ok: true, + codexAccountPickerModels: body.codexAccountPickerModels ?? null, + codexAccountPickerOptions: options, + }); + } + return response({ + codexAccountPickerEnabled: true, + codexAccountPickerModels: { main: ["gpt-5.1"] }, + codexAccountPickerOptions: options, + }); + }) as typeof fetch); + + const customizeToggle = () => toggles(host)[1]!; + expect(customizeToggle().getAttribute("aria-pressed")).toBe("true"); + + await act(async () => { + customizeToggle().click(); + await flush(); + }); + expect(customizeToggle().getAttribute("aria-pressed")).toBe("false"); + expect(puts.at(-1)).toBeNull(); + expect(host.querySelector(".codex-account-picker-models")).toBeNull(); + + await act(async () => { + customizeToggle().click(); + await flush(); + }); + expect(customizeToggle().getAttribute("aria-pressed")).toBe("true"); + expect(puts.at(-1)).toEqual({ main: ["gpt-5.1"] }); + const list = host.querySelector(".codex-account-picker-models"); + const checked = Array.from(list?.querySelectorAll('input[type="checkbox"]') ?? []).filter(i => i.checked); + expect(checked).toHaveLength(1); + }); + + test("a shared busy flag disables every mutating control while any save is in flight", async () => { + const options = [{ selector: "main", models: ["gpt-5.1"] }]; + const pendingPut = deferred(); + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") return pendingPut.promise; + return response({ + codexAccountPickerEnabled: true, + codexAccountPickerModels: {}, + codexAccountPickerOptions: options, + }); + }) as typeof fetch); + + const [mainToggle, customizeToggle] = toggles(host); + const checkbox = host.querySelector('.codex-account-picker-models input[type="checkbox"]'); + act(() => { checkbox?.click(); }); + const saveButton = Array.from(host.querySelectorAll("button")).find(b => b.textContent === "Save" || b.textContent === "Saving…"); + + act(() => { saveButton?.click(); }); + + expect(mainToggle?.disabled).toBe(true); + expect(customizeToggle?.disabled).toBe(true); + expect(host.querySelector('.codex-account-picker-models input[type="checkbox"]')?.disabled).toBe(true); + + await act(async () => { + pendingPut.resolve(response({ + ok: true, + codexAccountPickerModels: { main: ["gpt-5.1"] }, + codexAccountPickerOptions: options, + })); + await flush(); + }); + expect(mainToggle?.disabled).toBe(false); + }); + + test("an amber pending-refresh notice also surfaces for a customize-toggle save", async () => { + const options = [{ selector: "main", models: ["gpt-5.1"] }]; + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + return response({ + ok: true, + codexAccountPickerModels: {}, + codexAccountPickerOptions: options, + catalogRefreshPending: true, + }); + } + return response({ + codexAccountPickerEnabled: true, + codexAccountPickerModels: null, + codexAccountPickerOptions: options, + }); + }) as typeof fetch); + + await act(async () => { + toggles(host)[1]?.click(); + await flush(); + }); + const warning = host.querySelector(".codex-account-picker-feedback.is-warn"); + expect(warning).not.toBeNull(); + }); + + test("a pre-save poll cannot replace the confirmed model selection", async () => { + const stalePoll = deferred(); + let getCount = 0; + let pollCallback: (() => void) | null = null; + const originalSetInterval = testWindow.setInterval.bind(testWindow); + testWindow.setInterval = ((callback: TimerHandler, ms?: number, ...args: unknown[]) => { + if (typeof callback === "function") pollCallback = callback as () => void; + return originalSetInterval(callback, ms, ...args) as number; + }) as typeof testWindow.setInterval; + const options = [{ selector: "main", models: ["gpt-5.5"] }]; + const initial = { codexAccountPickerEnabled: true, codexAccountPickerModels: {}, codexAccountPickerOptions: options }; + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") return response({ ...initial, ok: true, codexAccountPickerModels: { main: ["gpt-5.5"] } }); + return ++getCount === 1 ? response(initial) : stalePoll.promise; + }) as typeof fetch); + await act(async () => { pollCallback?.(); await flush(); }); + const checkbox = () => host.querySelector('input[type="checkbox"]')!; + await act(async () => { checkbox().click(); await flush(); }); + await act(async () => { + Array.from(host.querySelectorAll("button")).find(button => button.textContent === "Save")!.click(); + await flush(); + }); + expect(checkbox().checked).toBe(true); + await act(async () => { stalePoll.resolve(response(initial)); await flush(); }); + expect(checkbox().checked).toBe(true); + }); + + test("Save sends the full multi-account map, preserving selections for accounts left untouched", async () => { + const options = [ + { selector: "main", models: ["gpt-5.1", "gpt-5.1-codex"] }, + { selector: "work", models: ["gpt-5.2", "gpt-5.2-fast"] }, + ]; + const initialModels = { main: ["gpt-5.1"], work: ["gpt-5.2"] }; + let lastPutBody: { codexAccountPickerModels?: Record } | null = null; + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + lastPutBody = JSON.parse(String(init.body)) as { codexAccountPickerModels?: Record }; + return response({ ok: true, codexAccountPickerModels: lastPutBody.codexAccountPickerModels, codexAccountPickerOptions: options }); + } + return response({ codexAccountPickerEnabled: true, codexAccountPickerModels: initialModels, codexAccountPickerOptions: options }); + }) as typeof fetch); + + const rows = Array.from(host.querySelectorAll(".codex-account-picker-account-row")); + expect(rows).toHaveLength(2); + const workRow = rows.find(row => row.textContent?.includes("work"))!; + const workExtraCheckbox = Array.from(workRow.querySelectorAll('input[type="checkbox"]')) + .find(box => !box.checked)!; + expect(workExtraCheckbox).toBeTruthy(); + + act(() => { workExtraCheckbox.click(); }); + await act(async () => { + Array.from(host.querySelectorAll("button")).find(b => b.textContent === "Save")!.click(); + await flush(); + }); + + expect(lastPutBody).not.toBeNull(); + expect(lastPutBody?.codexAccountPickerModels).toEqual({ + main: ["gpt-5.1"], + work: ["gpt-5.2", "gpt-5.2-fast"], + }); + + const mainRow = rows.find(row => row.textContent?.includes("main"))!; + const mainChecked = Array.from(mainRow.querySelectorAll('input[type="checkbox"]')).filter(b => b.checked); + expect(mainChecked).toHaveLength(1); + expect(mainChecked[0]?.parentElement?.textContent).toContain("gpt-5.1"); + const workChecked = Array.from(workRow.querySelectorAll('input[type="checkbox"]')).filter(b => b.checked); + expect(workChecked).toHaveLength(2); + }); + + test("a failed PUT while turning customize off restores the toggle and keeps the prior draft intact", async () => { + const options = [{ selector: "main", models: ["gpt-5.1", "gpt-5.1-codex"] }]; + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") return response({ error: "private server path" }, 500); + return response({ + codexAccountPickerEnabled: true, + codexAccountPickerModels: { main: ["gpt-5.1"] }, + codexAccountPickerOptions: options, + }); + }) as typeof fetch); + + const customizeToggle = () => Array.from(host.querySelectorAll("button.toggle"))[1]!; + expect(customizeToggle().getAttribute("aria-pressed")).toBe("true"); + const checkboxBefore = host.querySelector('input[type="checkbox"]'); + expect(checkboxBefore?.checked).toBe(true); + act(() => { host.querySelectorAll('input[type="checkbox"]')[1]!.click(); }); + + await act(async () => { + customizeToggle().click(); + await flush(); + }); + + expect(customizeToggle().getAttribute("aria-pressed")).toBe("true"); + expect(host.querySelector(".codex-account-picker-models")).not.toBeNull(); + const checkboxAfter = host.querySelector('input[type="checkbox"]'); + expect(checkboxAfter?.checked).toBe(true); + expect(host.querySelectorAll('input[type="checkbox"]')[1]!.checked).toBe(true); + const errorNotice = host.querySelector(".codex-account-picker-feedback.is-err"); + expect(errorNotice).not.toBeNull(); + expect(errorNotice?.textContent).toContain("Could not update per-account model customization"); + expect(errorNotice?.textContent).not.toContain("private server path"); + }); + + test("turning customize off and back on prunes a stale unavailable model instead of resubmitting it", async () => { + const options = [{ selector: "main", models: ["gpt-5.1"] }]; + const puts: unknown[] = []; + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + const body = JSON.parse(String(init.body)) as { codexAccountPickerModels?: Record | null }; + puts.push(body.codexAccountPickerModels); + return response({ + ok: true, + codexAccountPickerModels: body.codexAccountPickerModels ?? null, + codexAccountPickerOptions: options, + }); + } + return response({ + codexAccountPickerEnabled: true, + // "gpt-reserve" was saved earlier (e.g. codexDesktopAuthless was on) and no longer + // appears among this selector's current options. + codexAccountPickerModels: { main: ["gpt-5.1", "gpt-reserve"], former: ["gpt-5.1"] }, + codexAccountPickerOptions: options, + }); + }) as typeof fetch); + + const customizeToggle = () => toggles(host)[1]!; + expect(customizeToggle().getAttribute("aria-pressed")).toBe("true"); + + await act(async () => { + customizeToggle().click(); // off -> null deletes the persisted map + await flush(); + }); + expect(puts.at(-1)).toBeNull(); + + await act(async () => { + customizeToggle().click(); // on -> resubmits the draft, pruned to current options + await flush(); + }); + expect(customizeToggle().getAttribute("aria-pressed")).toBe("true"); + expect(puts.at(-1)).toEqual({ main: ["gpt-5.1"] }); + const list = host.querySelector(".codex-account-picker-models"); + const checked = Array.from(list?.querySelectorAll('input[type="checkbox"]') ?? []).filter(i => i.checked); + expect(checked).toHaveLength(1); + }); + + test("Save prunes a stale unavailable model from an untouched selector while preserving other accounts", async () => { + const options = [ + { selector: "main", models: ["gpt-5.1"] }, + { selector: "work", models: ["gpt-5.2", "gpt-5.2-fast"] }, + ]; + // "main" still carries "gpt-reserve" from an earlier save; it is no longer among main's + // current options. "work" is untouched aside from the checkbox click below. + const initialModels = { main: ["gpt-5.1", "gpt-reserve"], work: ["gpt-5.2"], former: ["gpt-5.1"] }; + let lastPutBody: { codexAccountPickerModels?: Record } | null = null; + const host = await mount((async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + lastPutBody = JSON.parse(String(init.body)) as { codexAccountPickerModels?: Record }; + return response({ ok: true, codexAccountPickerModels: lastPutBody.codexAccountPickerModels, codexAccountPickerOptions: options }); + } + return response({ codexAccountPickerEnabled: true, codexAccountPickerModels: initialModels, codexAccountPickerOptions: options }); + }) as typeof fetch); + + const rows = Array.from(host.querySelectorAll(".codex-account-picker-account-row")); + const workRow = rows.find(row => row.textContent?.includes("work"))!; + const workExtraCheckbox = Array.from(workRow.querySelectorAll('input[type="checkbox"]')) + .find(box => !box.checked)!; + expect(workExtraCheckbox).toBeTruthy(); + act(() => { workExtraCheckbox.click(); }); + + await act(async () => { + Array.from(host.querySelectorAll("button")).find(b => b.textContent === "Save")!.click(); + await flush(); + }); + + expect(lastPutBody).not.toBeNull(); + expect(lastPutBody?.codexAccountPickerModels).toEqual({ + main: ["gpt-5.1"], + work: ["gpt-5.2", "gpt-5.2-fast"], + }); + }); + }); diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index f239ce48b1..69e25a1eb4 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -454,7 +454,7 @@ export function shouldIncludeNativeOpenAi(config: Pick): type AccountSelectorConfig = Pick< OcxConfig, - "codexAccounts" | "codexAccountNamespaces" | "codexAccountPickerEnabled" + "codexAccounts" | "codexAccountNamespaces" | "codexAccountPickerEnabled" | "codexAccountPickerModels" >; function mainAccountSelectors(config: AccountSelectorConfig): string[] { @@ -466,7 +466,7 @@ function mainAccountSelectors(config: AccountSelectorConfig): string[] { /** Native slugs exposed to Claude Desktop show/export/apply (opt-out via claudeCode.desktopNativeModels). */ export function desktopVisibleNativeSlugs( config: Pick, + | "codexAccounts" | "codexAccountNamespaces" | "codexAccountPickerEnabled" | "codexAccountPickerModels">, ): string[] { if (config.claudeCode?.desktopNativeModels === false) return []; const visible = visibleNativeSlugs(config); @@ -821,7 +821,10 @@ export function accountBoundNativeOpenAiSlugsBySelector( if (rows) rows.add(slug); } } - return new Map([...result.entries()].map(([selector, slugs]) => [selector, [...slugs]])); + return new Map([...result.entries()].map(([selector, slugs]) => [selector, + [...slugs].filter(slug => config.codexAccountPickerModels === undefined + || config.codexAccountPickerModels[selector]?.includes(slug)), + ])); } /** Unknown native ids observed from Codex, excluding the static release set. */ diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 087b659140..e0c79905b7 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -893,6 +893,7 @@ export interface ObservedCatalogMergeInput { readonly hasPhysicalComboProvider: boolean; readonly includeNativeOpenAi: boolean; readonly accountBoundEntries: readonly RawEntry[]; + readonly keepBareNative?: boolean; readonly suppressedBareNativeSlugs?: ReadonlySet; readonly policy: ObservedCatalogMergePolicy; readonly openaiContextCap?: NativeContextLimitsInput; @@ -929,6 +930,7 @@ export function mergeCatalogEntriesFromObservedState({ hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, + keepBareNative = false, suppressedBareNativeSlugs = new Set(), policy, openaiContextCap, @@ -1287,7 +1289,7 @@ export function mergeCatalogEntriesFromObservedState({ // clobber a hide flag back to list. Bare ids disable every account clone; qualified ids disable // only their generated account row. const versionedEntries = applyMultiAgentMode( - applyNativeVisibility(mergedEntries, disabledModels, alignedAccountBoundEntries.length > 0, observedNativeSlugs), + applyNativeVisibility(mergedEntries, disabledModels, !keepBareNative && alignedAccountBoundEntries.length > 0, observedNativeSlugs), multiAgentMode, multiAgentV2Enabled, { keepNativeChatGptOnV1, preserveDefaultMultiAgentVersion: isReserveCatalogProjection }, @@ -1375,6 +1377,7 @@ export function mergeCatalogEntriesForSync( hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, + suppressedBareNativeSlugs, openaiContextCap, policy: { @@ -1908,7 +1911,15 @@ function writeRetainedCatalogSync({ accountNativeSlugs, accountNativeSlugsBySelector, reserve, - }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined) + }).filter(entry => { + // Reserve is appended outside the ordinary per-selector roster above, so it also needs + // the display preference here. This is not an entitlement or routing gate. + const native = trustedAccountBoundNativeCatalogSlug(entry); + if (native === undefined) return false; + const selector = String(entry.slug).split("/")[0]!; + return config.codexAccountPickerModels === undefined + || config.codexAccountPickerModels[selector]?.includes(native); + }) : []; catalog.models = mergeCatalogEntriesFromObservedState({ modelPickerOrder, @@ -1933,6 +1944,7 @@ function writeRetainedCatalogSync({ hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, + keepBareNative: config.codexAccountPickerModels !== undefined, suppressedBareNativeSlugs, openaiContextCap, nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames, diff --git a/src/codex/convergence.ts b/src/codex/convergence.ts index 2b8a8512c6..13c564584f 100644 --- a/src/codex/convergence.ts +++ b/src/codex/convergence.ts @@ -44,6 +44,7 @@ import { buildCatalogEntriesFromObservedState, CANONICAL_NATIVE_CATALOG_CONTENT_POLICY, finalizeAutoReviewModelOverride, + finishUpstreamNativeEntry, mergeCatalogEntriesFromObservedState, mergeCatalogModelsWithNativeRecovery, orderForSubagents, @@ -60,11 +61,19 @@ import { nativeContextLimits, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, + observedReserveCatalogSource, + upstreamNativeEntry, } from "./catalog/metadata"; import { trustedAccountBoundNativeCatalogSlug, visibleCodexAccountSelectors, } from "./catalog/account-models"; +import { + createReserveCatalogProjection, + RESERVE_LUNA_METADATA_SOURCE, + RESERVE_SOURCE_CATALOG_FIELD, +} from "./catalog/reserve"; +import { NATIVE_RESERVE_MODEL } from "./catalog/native-models"; import { clampCatalogModelsToObservedCodexSupport, supportedCodexReasoningEffortsFromObservedCatalog, @@ -300,6 +309,37 @@ function prepareCatalog( const observedNativeSlugs: string[] = []; const disabledNative = disabledNativeSlugs(config); const openaiContextCap = nativeContextLimits(config); + // Same Reserve projection writeRetainedCatalogSync applies on disk: without it, a settings PUT + // that selects gpt-reserve reports success but the row is missing from the in-memory candidate + // until the next retained sync or restart. + const reserveMainSelectors = accountSelectors.filter(selector => + isMainCodexAccountTarget(accountTargets.get(selector) ?? "")); + const reserveObservations = [ + ...(active?.models ?? []), + ...observedAccountNativeEntries, + ...(catalog.models ?? []), + ]; + const retainedReserve = active?.[RESERVE_SOURCE_CATALOG_FIELD]; + const retainedReserveSource = retainedReserve && typeof retainedReserve === "object" && !Array.isArray(retainedReserve) + ? observedReserveCatalogSource([retainedReserve as RawEntry], []) + : null; + const observedReserveSource = observedReserveCatalogSource( + // Cache invalidation carries historical bare observations alongside emitted models. + // Only unmarked observations are fresh enough to supersede the retained source. + reserveObservations.filter(entry => entry.slug === NATIVE_RESERVE_MODEL + && entry.opencodex_account_observed_native === undefined), reserveMainSelectors, + ) ?? retainedReserveSource ?? observedReserveCatalogSource(reserveObservations, reserveMainSelectors); + // This root is read only by OCX; retain it on the candidate so a later retained sync (or this + // same candidate if committed) keeps the genuine source metadata instead of falling back to Luna. + if (observedReserveSource) catalog[RESERVE_SOURCE_CATALOG_FIELD] = structuredClone(observedReserveSource); + else delete catalog[RESERVE_SOURCE_CATALOG_FIELD]; + const lunaSource = upstreamNativeEntry(RESERVE_LUNA_METADATA_SOURCE); + const reserve = createReserveCatalogProjection( + config, + reserveMainSelectors, + observedReserveSource, + lunaSource ? finishUpstreamNativeEntry(lunaSource, 9, openaiContextCap) : null, + ); const nativeCatalogModels = mergeCatalogModelsWithNativeRecovery( active?.models ?? catalog.models ?? [], [catalog.models ?? [], ...nativeRecoverySources], @@ -338,7 +378,16 @@ function prepareCatalog( openaiContextCap, accountNativeSlugs, accountNativeSlugsBySelector, - }).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined); + reserve, + }).filter(entry => { + // Reserve is appended outside the ordinary per-selector roster above, so it also needs + // the selective picker filter here: the same gate writeRetainedCatalogSync applies. + const native = trustedAccountBoundNativeCatalogSlug(entry); + if (native === undefined) return false; + const selector = String(entry.slug).split("/")[0]!; + return config.codexAccountPickerModels === undefined + || config.codexAccountPickerModels[selector]?.includes(native); + }); const gatheredProviderNames = new Set(enabledProviders.map(([name]) => name)); const selectedModelsByProvider = new Map>( enabledProviders.flatMap(([name, provider]) => ( @@ -370,6 +419,7 @@ function prepareCatalog( hasPhysicalComboProvider, includeNativeOpenAi, accountBoundEntries, + keepBareNative: config.codexAccountPickerModels !== undefined, suppressedBareNativeSlugs, openaiContextCap, nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames, diff --git a/src/config.ts b/src/config.ts index 4311e54eef..bf553b29d4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ import { chmodSync, constants as fsConstants, copyFileSync, existsSync, linkSync import { dirname, join } from "node:path"; import { Database } from "bun:sqlite"; import * as z from "zod/v4"; +import { isCodexAccountPickerModels } from "./config/codex-account-picker"; import { isValidProviderName, hasOwnProvider } from "./config/provider-name"; import { DEFAULT_SUBAGENT_MODELS, SUBAGENT_MODELS_VERSION } from "./config/subagent-models"; export { DEFAULT_SUBAGENT_MODELS } from "./config/subagent-models"; @@ -1260,6 +1261,7 @@ const configSchema = z.object({ // A malformed hand edit must degrade to false without discarding providers, accounts, // or the exact selector map. Live writes remain strict. codexAccountPickerEnabled: z.boolean().optional().catch(false), + codexAccountPickerModels: z.custom>(isCodexAccountPickerModels).optional().catch(undefined), // Same degrade-not-reject rule: a malformed hand edit hides Spark rather than discarding the // whole config. Hidden is also the default, so `catch(false)` and the default agree. showCodexSparkQuota: z.boolean().optional().catch(false), @@ -2203,6 +2205,10 @@ function malformedNativeSubagentFieldWarning(field: NativeSubagentPersistedField function malformedCodexAccountPickerWarning(rawParsed: unknown): string | null { const raw = rawConfigRecord(rawParsed); + if (raw && Object.hasOwn(raw, "codexAccountPickerModels") + && raw.codexAccountPickerModels !== undefined && !isCodexAccountPickerModels(raw.codexAccountPickerModels)) { + return "codexAccountPickerModels ignored: expected account selectors mapped to native model arrays"; + } if (!raw || !Object.hasOwn(raw, "codexAccountPickerEnabled")) return null; if (typeof raw.codexAccountPickerEnabled === "boolean") return null; return "codexAccountPickerEnabled ignored: expected a boolean"; @@ -2724,6 +2730,16 @@ function googleAntigravityStaticCatalogVersionError(value: unknown): string | nu return "schema_invalid: googleAntigravityStaticCatalogVersion: must be 1, 2, or omitted"; } +function codexAccountPickerModelsError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw) return null; + const descriptor = Object.getOwnPropertyDescriptor(raw, "codexAccountPickerModels"); + if (!descriptor && !("codexAccountPickerModels" in raw)) return null; + if (descriptor && "value" in descriptor + && (descriptor.value === undefined || isCodexAccountPickerModels(descriptor.value))) return null; + return "schema_invalid: codexAccountPickerModels: expected an own account-selector model map or omitted"; +} + function codexAccountPickerEnabledError(value: unknown): string | null { const raw = rawConfigRecord(value); if (!raw) return null; @@ -2854,6 +2870,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? codexAccountPrioritiesError(value) ?? codexQuotaAutoRefreshError(value) ?? codexAccountPickerEnabledError(value) + ?? codexAccountPickerModelsError(value) ?? emptyCompletionRetryError(value) ?? oauthOpenBrowserError(value) ?? runtimeRoleError(value) diff --git a/src/config/codex-account-picker.ts b/src/config/codex-account-picker.ts new file mode 100644 index 0000000000..fec721f00c --- /dev/null +++ b/src/config/codex-account-picker.ts @@ -0,0 +1,11 @@ +import { isValidProviderName } from "./provider-name"; + +/** Display preferences only: exact routing and provider entitlements remain authoritative. */ +export function isCodexAccountPickerModels(value: unknown): value is Record { + if (!value || typeof value !== "object" || Array.isArray(value) + || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) return false; + return Object.entries(value).every(([selector, models]) => + isValidProviderName(selector) && Array.isArray(models) + && models.every(model => typeof model === "string" + && /^(?:(?:gpt-|codex-|o[134]-)[A-Za-z0-9._-]+|o[134])$/.test(model))); +} diff --git a/src/server/fast-row.ts b/src/server/fast-row.ts index f07bb25b70..a2fd0ef735 100644 --- a/src/server/fast-row.ts +++ b/src/server/fast-row.ts @@ -151,8 +151,13 @@ export function fastRowBases(config: OcxConfig): (id: string) => boolean { if (shouldIncludeAccountBoundNativeOpenAi(config)) { // An EMPTY observed-entry list on purpose: the default argument reads the Codex models // cache and catalog from disk. The empty form still seeds every selector with the native - // model set, and anything publishable is in UPSTREAM_NATIVE_ENTRIES anyway. - for (const [selector, slugs] of accountBoundNativeOpenAiSlugsBySelector(config, [])) { + // model set, and anything publishable is in UPSTREAM_NATIVE_ENTRIES anyway. The picker + // selection is display-only: exact account routing still accepts every native base, so it + // must not make a previously-issued `/--fast` selector unparsable. + for (const [selector, slugs] of accountBoundNativeOpenAiSlugsBySelector({ + ...config, + codexAccountPickerModels: undefined, + }, [])) { for (const slug of slugs) bases.add(`${selector}/${slug}`); } } diff --git a/src/server/index.ts b/src/server/index.ts index dc3bc2561d..c7b426e5fb 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1588,12 +1588,76 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server + isMainCodexAccountTarget(accountTargets.get(selector) ?? "")); + if (reserveMainSelectors.length > 0) { + const { isEffectiveCodexDesktopAuthless } = await import("../codex/loopback-target"); + if (isEffectiveCodexDesktopAuthless(config)) { + const { NATIVE_RESERVE_MODEL } = await import("../codex/catalog/native-models"); + const { applyFullModelPickerOrder, buildCatalogEntriesFromObservedState, finishUpstreamNativeEntry } = await import("../codex/catalog/sync"); + const { isMultiAgentV2Enabled } = await import("../codex/features"); + const { createReserveCatalogProjection, isReserveCatalogProjection, RESERVE_LUNA_METADATA_SOURCE, RESERVE_SOURCE_CATALOG_FIELD } = await import("../codex/catalog/reserve"); + const { observedReserveCatalogSource, upstreamNativeEntry } = await import("../codex/catalog/metadata"); + const { readCurrentCodexCatalog, readCurrentCodexModelsCache } = await import("../codex/catalog/bundled"); + const onDiskCatalog = readCurrentCodexCatalog(); + const reserveObservations = [ + ...(onDiskCatalog?.models ?? []), + ...(readCurrentCodexModelsCache()?.models ?? []), + ]; + const retainedReserve = onDiskCatalog?.[RESERVE_SOURCE_CATALOG_FIELD]; + const retainedReserveSource = retainedReserve && typeof retainedReserve === "object" && !Array.isArray(retainedReserve) + ? observedReserveCatalogSource([retainedReserve as Record], []) + : null; + const observedReserveSource = observedReserveCatalogSource( + reserveObservations.filter(entry => entry.slug === NATIVE_RESERVE_MODEL + && entry.opencodex_account_observed_native === undefined), + reserveMainSelectors, + ) ?? retainedReserveSource ?? observedReserveCatalogSource(reserveObservations, reserveMainSelectors); + const lunaSource = upstreamNativeEntry(RESERVE_LUNA_METADATA_SOURCE); + const reserve = createReserveCatalogProjection( + config, + reserveMainSelectors, + observedReserveSource, + lunaSource ? finishUpstreamNativeEntry(lunaSource, 9, nativeContextLimits(config)) : null, + ); + if (reserve) { + const reserveEntries = buildCatalogEntriesFromObservedState({ + template: loadCatalogTemplate(), + gptSlugs: [], + goModels: [], + featured: config.subagentModels, + modelPickerOrder: config.modelPickerOrder, + wsEnabled: websocketsEnabled(config), + multiAgentMode: maMode as "v1" | "default" | "v2", + exactComboSlugs, + accountSelectors, + suppressedBareNativeSlugs, + disabledNativeAccountSlugs: new Set(), + multiAgentV2Enabled: isMultiAgentV2Enabled(), + openaiContextCap: nativeContextLimits(config), + accountNativeSlugs: [], + accountNativeSlugsBySelector: new Map(), + keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true, + reserve, + }).filter(entry => { + if (!isReserveCatalogProjection(entry)) return false; + const selector = String(entry.slug).split("/")[0]; + return config.codexAccountPickerModels === undefined + || config.codexAccountPickerModels[selector]?.includes(NATIVE_RESERVE_MODEL); + }); + entries.push(...reserveEntries); + if (reserveEntries.length > 0) liveObservedNativeSlugs.add(NATIVE_RESERVE_MODEL); + applyFullModelPickerOrder(entries, config.modelPickerOrder ?? []); + } + } + } return jsonResponse({ models: applyNativeVisibility( entries, disabledModels, - accountSelectors.length > 0, - new Set(accountNativeSlugs), + accountSelectors.length > 0 && config.codexAccountPickerModels === undefined, + liveObservedNativeSlugs, ), }, 200, req, policy); } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 527178ba0f..17f1d26c5d 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -1,3 +1,8 @@ +import { isEffectiveCodexDesktopAuthless } from "../../codex/loopback-target"; +import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../../codex/account-namespaces"; +import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models"; +import { isCodexAccountPickerModels } from "../../config/codex-account-picker"; +import { accountBoundNativeOpenAiSlugsBySelector } from "../../codex/catalog/metadata"; import type { IntegrationClientId } from "../../integrations/registry"; import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; @@ -178,6 +183,23 @@ interface ClientIntegrationSyncOutcome { * does not fail the sync: Codex is the one that matters for routing, and a broken Grok file * should surface as a warning, not as a 500 on a command that did its main job. */ +function accountPickerSettings(config: OcxConfig) { + const choices = { ...config, codexAccountPickerEnabled: true }; + delete choices.codexAccountPickerModels; + // Catalog candidates are display choices, not proof of upstream account entitlement. + const targets = new Map(codexAccountNamespaceEntries(choices)); + return { + codexAccountPickerModels: config.codexAccountPickerModels ?? null, + codexAccountPickerOptions: [...accountBoundNativeOpenAiSlugsBySelector(choices)] + .map(([selector, models]) => ({ + selector, + models: isEffectiveCodexDesktopAuthless(choices) && isMainCodexAccountTarget(targets.get(selector) ?? "") + ? [...models, NATIVE_RESERVE_MODEL] + : models, + })), + }; +} + export async function syncEnabledClientIntegrations( port: number | undefined, config: OcxConfig, @@ -315,6 +337,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise | undefined; + let initializedPickerNamespaces: OcxConfig["codexAccountNamespaces"]; + if (body.codexAccountPickerModels !== undefined && body.codexAccountPickerModels !== null) { + const choices = { ...config }; + if (typeof body.codexDesktopAuthless === "boolean") choices.codexDesktopAuthless = body.codexDesktopAuthless; + if (body.codexAccountPickerEnabled === true) { + choices.codexAccountPickerEnabled = true; + if (initializeDefaultCodexAccountNamespaces(choices)) { + initializedPickerNamespaces = choices.codexAccountNamespaces; + } + } + const available = new Map(accountPickerSettings(choices).codexAccountPickerOptions + .map(option => [option.selector, new Set(option.models)])); + selectedAccountModels = {}; + for (const [selector, models] of Object.entries(body.codexAccountPickerModels as Record)) { + const candidates = available.get(selector); + if (!candidates) { + // Discard saved display choices for deleted accounts or removed/renamed bindings. + if (Object.hasOwn(choices.codexAccountNamespaces ?? {}, selector) + || Object.hasOwn(config.codexAccountPickerModels ?? {}, selector)) continue; + return jsonResponse({ error: "Unknown Codex account selector" }, 400); + } + const previous = new Set(config.codexAccountPickerModels?.[selector] ?? []); + if (models.some(model => !candidates.has(model) && !previous.has(model))) { + return jsonResponse({ error: "Model is not available for this Codex account selector" }, 400); + } + // Eligibility can change after a selection was saved; drop unchanged stale choices. + selectedAccountModels[selector] = models.filter(model => candidates.has(model)); + } + } if (body.showCodexSparkQuota !== undefined && typeof body.showCodexSparkQuota !== "boolean") { return jsonResponse({ error: "showCodexSparkQuota boolean is required" }, 400); } @@ -504,6 +562,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise; /** * Show the GPT-5.3-Codex-Spark 5-hour and weekly windows on Codex quota surfaces. Default false. * diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index 9769012e1d..6cf4b6b794 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -204,7 +204,7 @@ Pool mode routes across main plus added Codex credentials. Key rules: routing-profile selector prefixes are exact-case (`src/codex/account-namespaces.ts`, `src/codex/account-namespace-match.ts`, `src/routing/profile-namespace.ts`). - **Selector labels carry no account-role semantics.** When at least one selector is advertisable, - the Codex catalog clones each supported native row per selector and hides the bare picker rows; + the legacy Codex catalog clones each supported native row per selector and hides the bare picker rows; bare ids remain routable and stay in raw `/v1/models` unless explicitly disabled. Missing stored account targets are not advertised, and private account ids never become catalog labels. `codexAccountPickerEnabled: false` hides generated rows without deleting exact routing bindings; @@ -541,3 +541,13 @@ Claude ModelInfo ordering receives optional `{ modelPickerOrder, featured }` aft It orders routed output groups after alias deduplication, preserving the collision winner and base/1M/Fast siblings. Native groups and explicit Desktop profile ownership are unchanged. Native Codex advertisements still follow display priority; private guidance ranks do not freeze them. + + +### Selective account picker + +An optional `codexAccountPickerModels` map filters generated account rows per public selector. +Absent preserves legacy projection; an empty map selects none. In selective mode, catalog sync +keeps common native rows visible, subject to existing disabled-model and provider gates. +The Advanced settings secondary customization toggle owns this opt-in. Settings updates use +the existing persistence rollback and catalog convergence flow. Exact routing and credential +selection remain independent of this display preference. diff --git a/tests/codex-integration/codex-catalog.test.ts b/tests/codex-integration/codex-catalog.test.ts index 38f362c81b..c6893f71a8 100644 --- a/tests/codex-integration/codex-catalog.test.ts +++ b/tests/codex-integration/codex-catalog.test.ts @@ -3172,6 +3172,21 @@ test.each(["retained", "convergence"] as const)("%s persists and restores native expect((await write({ "gpt-5.6-sol": "Changed Sol" })).find(row => row.slug === "gpt-5.6-sol")?.display_name) .toBe("Changed Sol"); expect(await write()).toEqual(original); + config.codexAccountNamespaces = { main: "@main" }; + config.codexAccountPickerEnabled = true; + config.codexAccountPickerModels = { main: ["gpt-5.5"] }; + const selective = await write(); + expect(selective.filter(row => row.opencodex_catalog_kind === "account-selector-v1").map(row => row.slug)) + .toEqual(["main/gpt-5.5"]); + expect(selective.find(row => row.slug === "gpt-5.5")?.visibility).toBe("list"); + config.codexAccountPickerModels = {}; + const empty = await write(); + expect(empty.filter(row => row.opencodex_catalog_kind === "account-selector-v1")).toEqual([]); + expect(empty.find(row => row.slug === "gpt-5.5")?.visibility).toBe("list"); + delete config.codexAccountPickerModels; + const legacy = await write(); + expect(legacy.find(row => row.slug === "main/gpt-5.5")?.visibility).toBe("list"); + expect(legacy.find(row => row.slug === "gpt-5.5")?.visibility).toBe("hide"); expect(fetchCalls).toBe(0); } finally { try { @@ -7454,3 +7469,25 @@ describe("Codex 0.151 catalog contract fields", () => { }); }); }); + + +test("selective account projection preserves common pool models alongside selected rows", () => { + const native = nativeTemplate(); + const account = { ...native, slug: "main/gpt-5.5", opencodex_catalog_kind: "account-selector-v1" }; + const selected = mergeObservedForTest({ + catalogModels: [{ ...native, visibility: "hide" }], routedEntries: [], + accountBoundEntries: [account], keepBareNative: true, + }); + expect(selected.find(row => row.slug === "gpt-5.5")?.visibility).toBe("list"); + expect(selected.find(row => row.slug === "main/gpt-5.5")?.visibility).toBe("list"); + const legacy = mergeObservedForTest({ + catalogModels: [native], routedEntries: [], accountBoundEntries: [account], + }); + expect(legacy.find(row => row.slug === "gpt-5.5")?.visibility).toBe("hide"); + const disabled = mergeObservedForTest({ + catalogModels: [native], routedEntries: [], accountBoundEntries: [account], keepBareNative: true, + disabledModels: new Set(["gpt-5.5"]), + }); + expect(disabled.find(row => row.slug === "gpt-5.5")?.visibility).toBe("hide"); + expect(disabled.find(row => row.slug === "main/gpt-5.5")?.visibility).toBe("hide"); +}); diff --git a/tests/codex-integration/codex-convergence-account-selectors.test.ts b/tests/codex-integration/codex-convergence-account-selectors.test.ts index 9602e91309..157d809ce3 100644 --- a/tests/codex-integration/codex-convergence-account-selectors.test.ts +++ b/tests/codex-integration/codex-convergence-account-selectors.test.ts @@ -1,3 +1,7 @@ +import { handleManagementAPI } from "../../src/server/management-api"; +import { createManagementConvergeCodex } from "../../src/codex/management-convergence"; +import { ManagementRequest } from "../helpers/management-auth"; +import { startupHealthFixture } from "../helpers/startup-health"; import { afterEach, beforeEach, expect, setDefaultTimeout, spyOn, test } from "bun:test"; import { chmodSync, @@ -46,7 +50,8 @@ import { import { markModelsFetchFailure } from "../../src/codex/model-cache"; import { legacyCustomModelCatalogSlugs } from "../../src/codex/custom-model-catalog-migration"; import { resetCodexModelEntitlementCacheForTests } from "../../src/codex/model-entitlements"; -import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../src/codex/catalog/native-models"; +import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "../../src/codex/catalog/native-models"; +import { RESERVE_LUNA_METADATA_SOURCE, RESERVE_METADATA_SOURCE_FIELD } from "../../src/codex/catalog/reserve"; import { removeCodexAccountCredential, saveCodexAccountCredential } from "../../src/codex/account-store"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -1234,3 +1239,52 @@ test("both writers restore pristine native priorities after featured-model trans expect(finalModels.find(entry => entry.slug === "gpt-5.6-sol")?.priority).toBe(41); expect(finalModels.find(entry => entry.slug === "gpt-5.5")?.priority).toBe(57); }); + +test("convergence projects a main-account Reserve row immediately, and the selective filter still gates it", async () => { + // Regression for the P2 finding: prepareCatalog (used by convergeCodexCatalog) lacked the + // same ReserveCatalogProjection logic writeRetainedCatalogSync already applies, so a settings + // PUT that selected gpt-reserve reported refresh success while main/gpt-reserve was actually + // missing from the live catalog until the next retained sync or restart. + writeCatalog([nativeEntry()]); + const selectiveConfig: OcxConfig = { + ...config(true), + codexDesktopAuthless: true, + codexAccountPickerModels: { desktop: [NATIVE_RESERVE_MODEL] }, + }; + + const settingsConfig = { ...selectiveConfig, codexAccountPickerModels: {} }; + saveConfig(settingsConfig); + const request = new ManagementRequest("http://127.0.0.1/api/settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ codexAccountPickerModels: selectiveConfig.codexAccountPickerModels }), + }); + const response = await handleManagementAPI(request, new URL(request.url), settingsConfig, { + saveConfigPreservingClaudeCode: saveConfig, + createManagementConvergeCodex, + getCachedStartupHealth: async () => startupHealthFixture(), + }); + expect(response!.status).toBe(200); + expect((await response!.json()).catalogRefreshPending).toBe(false); + const withReserveSelected = JSON.parse(readFileSync(catalogPath, "utf8")) as RawCatalog; + const reserveSlug = `desktop/${NATIVE_RESERVE_MODEL}`; + const reserveEntry = withReserveSelected.models?.find(entry => entry.slug === reserveSlug); + // The projection is present on the very first convergence pass, with no prior retained sync + // and no restart in between. + expect(reserveEntry).toBeDefined(); + expect(reserveEntry?.opencodex_catalog_kind).toBe(CODEX_ACCOUNT_BOUND_CATALOG_KIND); + // No account observation of the reserve model exists yet, so the projection is built from the + // genuine pinned Luna source (never a fabricated row) — the same fallback writeRetainedCatalogSync uses. + expect(reserveEntry?.[RESERVE_METADATA_SOURCE_FIELD]).toBe(RESERVE_LUNA_METADATA_SOURCE); + // The side selector was never granted the main account, so it must never receive a Reserve row. + expect(withReserveSelected.models?.some(entry => entry.slug === `team/${NATIVE_RESERVE_MODEL}`)).toBe(false); + // A selected-but-unrelated bare native model stays off the projected selector row: Reserve is + // additive, not a blanket unlock of every account-native model for that selector. + expect(withReserveSelected.models?.some(entry => entry.slug === "desktop/gpt-5.6-sol")).toBe(false); + + // Turning the map to an empty selection (customize on, nothing chosen yet) must still gate the + // final projected Reserve entry through the same selective filter as any other account row. + const emptySelectionConfig: OcxConfig = { ...selectiveConfig, codexAccountPickerModels: {} }; + const withNoneSelected = await convergeCatalog(emptySelectionConfig); + expect(withNoneSelected.models?.some(entry => entry.slug === reserveSlug)).toBe(false); +}); diff --git a/tests/codex-integration/fast-row.test.ts b/tests/codex-integration/fast-row.test.ts index a8e9525e49..a433c7ff8e 100644 --- a/tests/codex-integration/fast-row.test.ts +++ b/tests/codex-integration/fast-row.test.ts @@ -247,6 +247,22 @@ describe("publication and parsing agree", () => { expect(parseFastRowId("desktop/gpt-5.6-sol--fast", config, new Set(), bases)) .toEqual({ baseId: "desktop/gpt-5.6-sol" }); }); + + test("a selective picker filter cannot make an exact-account fast base unparsable", () => { + // `codexAccountPickerModels` filters catalog display only. The underlying exact-account + // route remains valid, and the Fast parser must keep stripping its synthetic suffix even + // when this base is deliberately absent from the account's picker rows. + const config = configWith({ + openai: provider({ authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex" }), + }, { + codexAccountNamespaces: { side: "@main" }, + codexAccountPickerModels: { side: ["gpt-daybreak-blue-latest"] }, + } as Partial); + const bases = fastRowBases(config); + expect(bases("side/gpt-5.6-sol")).toBe(true); + expect(parseFastRowId("side/gpt-5.6-sol--fast", config, new Set(), bases)) + .toEqual({ baseId: "side/gpt-5.6-sol" }); + }); }); describe("routable bases do not depend on the live-model cache", () => { diff --git a/tests/codex-integration/native-model-toggle.test.ts b/tests/codex-integration/native-model-toggle.test.ts index 0ac18f1ad2..30d1a1f2bf 100644 --- a/tests/codex-integration/native-model-toggle.test.ts +++ b/tests/codex-integration/native-model-toggle.test.ts @@ -1,3 +1,5 @@ +import { isCodexAccountPickerModels } from "../../src/config/codex-account-picker"; +import { accountBoundNativeOpenAiSlugsBySelector } from "../../src/codex/catalog/metadata"; import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; @@ -899,3 +901,32 @@ describe("#2574 a stale on-disk row is what a subagent reads", () => { expect(row.context_window).toBe(300_000); }); }); + + +test("selective account catalog lists only chosen pairs and preserves legacy omission", () => { + const config: OcxConfig = makeConfig({ + codexAccountPickerEnabled: true, + codexAccountNamespaces: { main: "@main", side: "stored-side" }, + codexAccounts: [{ id: "stored-side", email: "side@example.test" }], + codexAccountPickerModels: { main: ["gpt-5.5"], side: ["gpt-6-astra"] }, + }); + const selected = accountBoundNativeOpenAiSlugsBySelector(config, []); + expect(selected.get("main")).toEqual(["gpt-5.5"]); + expect(selected.get("side")).toEqual(["gpt-6-astra"]); + config.codexAccountPickerModels = {}; + expect([...accountBoundNativeOpenAiSlugsBySelector(config, []).values()].flat()).toEqual([]); + delete config.codexAccountPickerModels; + expect(accountBoundNativeOpenAiSlugsBySelector(config, []).get("main")!.length).toBeGreaterThan(1); + config.codexAccountPickerEnabled = false; + expect(accountBoundNativeOpenAiSlugsBySelector(config, []).size).toBe(0); +}); + + +test("account picker validation accepts observed o-series catalog choices", () => { + const models = ["o1-mini", "o3-mini", "o4-mini"]; + const config = makeConfig({ codexAccountPickerEnabled: true, codexAccountNamespaces: { main: "@main" } }); + const observed = models.map(slug => ({ ...nativeTemplate(), slug, visibility: "list", supported_in_api: true })); + const choices = accountBoundNativeOpenAiSlugsBySelector(config, observed).get("main")!; + for (const model of models) expect(choices).toContain(model); + expect(isCodexAccountPickerModels({ main: [...choices] })).toBe(true); +}); diff --git a/tests/config/settings-stream-mode.test.ts b/tests/config/settings-stream-mode.test.ts index 514ba6110a..4a0f916531 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getConfigPath, loadConfig, saveConfig } from "../../src/config"; +import { getConfigPath, loadConfig, saveConfig, validateConfigCandidate } from "../../src/config"; import { handleManagementAPI, type ManagementApiDeps } from "../../src/server/management-api"; import { invalidateStartupHealthCache } from "../../src/server/startup-health-cache"; import { USAGE_RANGES, USAGE_SURFACES } from "../../src/usage/summary"; @@ -307,6 +307,7 @@ describe("PUT /api/settings", () => { const config = baseConfig(); const res = await putSettings(config, {}); expect(res!.status).toBe(400); + expect((await res!.json()).error).toContain("codexAccountPickerModels"); }); test.each([[null], [[]], ["settings"], [42]] as const)( @@ -640,3 +641,195 @@ describe("config.json schema resilience", () => { }); }); import { ManagementRequest as Request } from "../helpers/management-auth"; + + +describe("selective account model settings", () => { + test("saves selected models, converges, and null restores legacy mode", async () => { + const config = baseConfig(); + config.codexAccountNamespaces = { main: "@main" }; + config.codexAccountPickerEnabled = true; + let convergences = 0; + const deps: ManagementApiDeps = { + saveConfigPreservingClaudeCode: saveConfig, + createManagementConvergeCodex: catalogConvergenceFactory(() => { convergences++; }), + }; + const selected = { main: ["gpt-5.5"] }; + const saved = await putSettings(config, { codexAccountPickerModels: selected }, deps); + expect(saved!.status).toBe(200); + expect(await saved!.json()).toMatchObject({ codexAccountPickerModels: selected }); + expect(loadConfig().codexAccountPickerModels).toEqual(selected); + expect(convergences).toBe(1); + const loaded = await (await getSettings(config))!.json(); + expect(loaded.codexAccountPickerOptions.find((option: { selector: string }) => option.selector === "main").models).toContain("gpt-5.5"); + await putSettings(config, { codexAccountPickerModels: selected }, deps); + expect(convergences).toBe(1); + await putSettings(config, { codexAccountPickerModels: null }, deps); + expect(config.codexAccountPickerModels).toBeUndefined(); + expect(loadConfig().codexAccountPickerModels).toBeUndefined(); + expect(convergences).toBe(2); + }); + + test.each([[], "all", { main: "gpt-5.5" }, { main: ["other/gpt-5.5"] }, { missing: ["gpt-5.5"] }, JSON.parse('{"__proto__":[]}')])( + "rejects malformed or unknown selections without persisting (%j)", async value => { + let writes = 0; + const response = await putSettings(baseConfig(), { codexAccountPickerModels: value }, { + saveConfigPreservingClaudeCode: () => { writes++; }, + }); + expect(response!.status).toBe(400); + expect(writes).toBe(0); + }, + ); + + test("rejects an unavailable model on a known selector before any mutation", async () => { + const config = baseConfig(); + config.codexAccountNamespaces = { main: "@main" }; + config.codexAccountPickerEnabled = true; + config.codexAccountPickerModels = { main: ["gpt-5.5"] }; + saveConfig(config); + const stored = readFileSync(getConfigPath(), "utf-8"); + let writes = 0; + let convergences = 0; + const response = await putSettings(config, { + codexAccountPickerModels: { main: ["gpt-5.5", "gpt-not-real"] }, + codexAccountPickerEnabled: false, + }, { + saveConfigPreservingClaudeCode: () => { writes++; }, + createManagementConvergeCodex: catalogConvergenceFactory(() => { convergences++; }), + }); + expect(response!.status).toBe(400); + expect(await response!.json()).toEqual({ error: "Model is not available for this Codex account selector" }); + expect(writes).toBe(0); + expect(convergences).toBe(0); + expect(config.codexAccountPickerModels).toEqual({ main: ["gpt-5.5"] }); + expect(config.codexAccountPickerEnabled).toBe(true); + expect(readFileSync(getConfigPath(), "utf-8")).toBe(stored); + }); + + test("failed persistence restores selection and original switch state", async () => { + const config = baseConfig(); + const previous = { main: ["gpt-5.5"] }; + config.codexAccountNamespaces = { main: "@main" }; + config.codexAccountPickerModels = previous; + await expect(putSettings(config, { codexAccountPickerModels: {}, codexAccountPickerEnabled: true }, { + saveConfigPreservingClaudeCode: () => { throw new Error("disk full"); }, + })).rejects.toThrow("disk full"); + expect(config.codexAccountPickerModels).toBe(previous); + expect(config.codexAccountPickerEnabled).toBeUndefined(); + }); +}); + + +test("malformed stored account model preferences preserve providers and reject live writes", () => { + const config = baseConfig(); + saveConfig(config); + writeFileSync(getConfigPath(), JSON.stringify({ ...config, codexAccountPickerModels: "invalid" })); + const loaded = loadConfig(); + expect(loaded.providers.openai).toEqual(config.providers.openai); + expect(loaded.codexAccountPickerModels).toBeUndefined(); + expect(validateConfigCandidate({ ...config, codexAccountPickerModels: "invalid" })).toMatchObject({ ok: false }); +}); + + +test("o-series account model preferences survive config validation and persistence", () => { + const config = { ...baseConfig(), codexAccountPickerModels: { main: ["o1", "o3", "o1-mini", "o3-mini", "o4-mini"] } }; + expect(validateConfigCandidate(config)).toMatchObject({ ok: true }); + saveConfig(config); + expect(loadConfig().codexAccountPickerModels).toEqual(config.codexAccountPickerModels); +}); + + +test("first enable and selected models save atomically with generated selectors", async () => { + const config = baseConfig(); + const response = await putSettings(config, { codexAccountPickerEnabled: true, codexAccountPickerModels: { main: ["gpt-5.5"] } }, { + saveConfigPreservingClaudeCode: saveConfig, + createManagementConvergeCodex: catalogConvergenceFactory(() => {}), + }); + expect(response!.status).toBe(200); + expect(config.codexAccountNamespaces).toEqual({ main: "@main" }); + expect(loadConfig().codexAccountPickerModels).toEqual({ main: ["gpt-5.5"] }); +}); + +test("invalid atomic selection does not initialize selectors or write settings", async () => { + const config = baseConfig(); + const before = structuredClone(config); + let writes = 0; + const response = await putSettings(config, { codexAccountPickerEnabled: true, codexAccountPickerModels: { main: ["gpt-not-real"] } }, { + saveConfigPreservingClaudeCode: () => { writes++; }, + }); + expect(response!.status).toBe(400); + expect(config).toEqual(before); + expect(writes).toBe(0); +}); + +test("saving after account removal prunes stale choices, including a restored draft", async () => { + const config = baseConfig(); + config.codexAccountPickerEnabled = true; + config.codexAccountNamespaces = { main: "@main", removed: "deleted-pool" }; + config.codexAccounts = []; + const draft = { main: ["gpt-5.5"], removed: ["gpt-5.5"] }; + config.codexAccountPickerModels = structuredClone(draft); + const deps: ManagementApiDeps = { saveConfigPreservingClaudeCode: saveConfig, createManagementConvergeCodex: catalogConvergenceFactory(() => {}) }; + for (const restore of [false, true]) { + if (restore) await putSettings(config, { codexAccountPickerModels: null }, deps); + const response = await putSettings(config, { codexAccountPickerModels: draft }, deps); + expect(response!.status).toBe(200); + expect(config.codexAccountPickerModels).toEqual({ main: ["gpt-5.5"] }); + expect(loadConfig().codexAccountPickerModels).toEqual({ main: ["gpt-5.5"] }); + expect(config.codexAccountNamespaces.removed).toBe("deleted-pool"); + } + const response = await putSettings(config, { codexAccountPickerModels: { unknown: ["gpt-5.5"] } }, deps); + expect(response!.status).toBe(400); +}); + + +test("eligible Reserve is selectable only for main-account bindings", async () => { + const config = baseConfig(); + config.codexAccountPickerEnabled = true; + config.codexDesktopAuthless = true; + config.codexAccountNamespaces = { main: "@main", side: "pool-side" }; + config.codexAccounts = [{ id: "pool-side", email: "side@example.test" }]; + const options = (await (await getSettings(config))!.json()).codexAccountPickerOptions; + expect(options.find((row: { selector: string }) => row.selector === "main").models).toContain("gpt-reserve"); + expect(options.find((row: { selector: string }) => row.selector === "side").models).not.toContain("gpt-reserve"); + const deps: ManagementApiDeps = { saveConfigPreservingClaudeCode: saveConfig, createManagementConvergeCodex: catalogConvergenceFactory(() => {}) }; + expect((await putSettings(config, { codexAccountPickerModels: { main: ["gpt-reserve"] } }, deps))!.status).toBe(200); + expect(loadConfig().codexAccountPickerModels).toEqual({ main: ["gpt-reserve"] }); + expect((await putSettings(config, { codexAccountPickerModels: { side: ["gpt-reserve"] } }, deps))!.status).toBe(400); + config.codexDesktopAuthless = false; + expect((await putSettings(config, { codexAccountPickerModels: { main: ["gpt-reserve"] } }, deps))!.status).toBe(200); + expect(config.codexAccountPickerModels).toEqual({ main: [] }); + expect((await putSettings(config, { codexAccountPickerModels: { main: ["gpt-reserve"] } }, deps))!.status).toBe(400); + expect((await putSettings(config, { codexDesktopAuthless: true, codexAccountPickerModels: { main: ["gpt-reserve"] } }, deps))!.status).toBe(200); + config.runtimeRole = "client"; + expect((await putSettings(config, { codexAccountPickerModels: { main: ["gpt-reserve"] } }, deps))!.status).toBe(200); + expect(config.codexAccountPickerModels).toEqual({ main: [] }); + expect((await putSettings(config, { codexAccountPickerModels: { main: ["gpt-reserve"] } }, deps))!.status).toBe(400); +}); + + +test("an eligibility change does not let unchanged stale selections block the next edit", async () => { + const config = baseConfig(); + config.codexAccountPickerEnabled = true; + config.codexDesktopAuthless = true; + config.codexAccountNamespaces = { main: "@main" }; + config.codexAccountPickerModels = { main: ["gpt-reserve", "gpt-5.5"] }; + const deps: ManagementApiDeps = { saveConfigPreservingClaudeCode: saveConfig, createManagementConvergeCodex: catalogConvergenceFactory(() => {}) }; + expect((await putSettings(config, { codexDesktopAuthless: false }, deps))!.status).toBe(200); + const response = await putSettings(config, { codexAccountPickerModels: { main: ["gpt-reserve", "gpt-5.5", "gpt-5.6-sol"] } }, deps); + expect(response!.status).toBe(200); + expect(loadConfig().codexAccountPickerModels).toEqual({ main: ["gpt-5.5", "gpt-5.6-sol"] }); + expect((await putSettings(config, { codexAccountPickerModels: { main: ["gpt-not-real"] } }, deps))!.status).toBe(400); +}); + + +test("a removed namespace is pruned from saved selections but a new unknown key is rejected", async () => { + const config = baseConfig(); + config.codexAccountPickerEnabled = true; + config.codexAccountNamespaces = { main: "@main" }; + config.codexAccountPickerModels = { main: ["gpt-5.5"], former: ["gpt-5.5"] }; + const deps: ManagementApiDeps = { saveConfigPreservingClaudeCode: saveConfig, createManagementConvergeCodex: catalogConvergenceFactory(() => {}) }; + const response = await putSettings(config, { codexAccountPickerModels: { main: ["gpt-5.6-sol"], former: ["gpt-5.5"] } }, deps); + expect(response!.status).toBe(200); + expect(loadConfig().codexAccountPickerModels).toEqual({ main: ["gpt-5.6-sol"] }); + expect((await putSettings(config, { codexAccountPickerModels: { main: ["gpt-5.6-sol"], unknown: ["gpt-5.5"] } }, deps))!.status).toBe(400); +}); diff --git a/tests/server/server-auth.test.ts b/tests/server/server-auth.test.ts index cc5eb8f1fd..04169bf95f 100644 --- a/tests/server/server-auth.test.ts +++ b/tests/server/server-auth.test.ts @@ -244,6 +244,9 @@ async function startPoolRetryHarness( omitCredentialAccountIds?: string[]; combos?: OcxConfig["combos"]; modelRosterByAccount?: Record; + codexAccountPickerModels?: Record; + disabledModels?: string[]; + codexDesktopAuthless?: boolean; } = {}, ): Promise { await removeTestDirBestEffort(TEST_DIR); @@ -306,6 +309,9 @@ async function startPoolRetryHarness( ], activeCodexAccountId: options.activeAccountId ?? "pool-a", ...(options.accountNamespaces ? { codexAccountNamespaces: options.accountNamespaces } : {}), + ...(options.codexAccountPickerModels ? { codexAccountPickerModels: options.codexAccountPickerModels } : {}), + ...(options.disabledModels ? { disabledModels: options.disabledModels } : {}), + ...(options.codexDesktopAuthless ? { codexDesktopAuthless: true } : {}), ...(options.pausedAccountIds ? { pausedCodexAccountIds: options.pausedAccountIds } : {}), ...(options.visionSidecarModel ? { visionSidecar: { model: options.visionSidecarModel } } : {}), ...(options.websockets ? { websockets: true } : {}), @@ -4583,4 +4589,88 @@ describe("POST /opencodex-session pairing body bound", () => { await server.stop(true); } }); + +}); + +describe("GET /v1/models Codex client-version account-picker projection", () => { + test("keeps common native rows for both specific and empty selective maps", async () => { + const commonModel = "gpt-5.6-sol"; + const selectedModel = "gpt-5.5"; + for (const codexAccountPickerModels of [ + { side: [selectedModel] }, + {}, + ]) { + const harness = await startPoolRetryHarness( + () => Response.json({ id: "unused", status: "completed", output: [] }), + { + accountNamespaces: { side: "pool-a" }, + codexAccountPickerModels, + modelRosterByAccount: { + "acct-pool-a": [commonModel, selectedModel], + "acct-pool-b": [commonModel], + }, + }, + ); + try { + const response = await originalGlobalFetch( + new URL("/v1/models?client_version=0.150.0", harness.server.url), + { headers: { authorization: "Bearer inbound-token" } }, + ); + expect(response.status).toBe(200); + const payload = await response.json() as { models: Array<{ slug: string }> }; + const slugs = payload.models.map(model => model.slug); + expect(slugs).toContain(commonModel); + expect(slugs).not.toContain("side/" + commonModel); + if (Object.keys(codexAccountPickerModels).length > 0) { + expect(slugs).toContain("side/" + selectedModel); + } else { + expect(slugs).not.toContain("side/" + selectedModel); + } + } finally { + await stopPoolRetryHarness(harness); + } + } + }); + + test("projects Reserve only for the selected authless main selector", async () => { + const reserveModel = "gpt-reserve"; + for (const [codexDesktopAuthless, codexAccountPickerModels, expected, disabledModels] of [ + [true, { main: [reserveModel] }, true, []], + [true, { main: [reserveModel] }, false, [reserveModel]], + [true, { main: [reserveModel] }, false, [`main/${reserveModel}`]], + [true, { main: [] }, false, []], + [true, {}, false, []], + [true, { side: [reserveModel] }, false, []], + [false, { main: [reserveModel] }, false, []], + ] as const) { + const harness = await startPoolRetryHarness( + () => Response.json({ id: "unused", status: "completed", output: [] }), + { + accountNamespaces: { main: "@main", side: "pool-a" }, + codexDesktopAuthless, + codexAccountPickerModels, + disabledModels: [...disabledModels], + }, + ); + try { + const response = await originalGlobalFetch( + new URL("/v1/models?client_version=0.150.0", harness.server.url), + { headers: { authorization: "Bearer inbound-token" } }, + ); + expect(response.status).toBe(200); + const payload = await response.json() as { models: Array<{ slug: string; visibility?: string }> }; + const slugs = payload.models.map(model => model.slug); + const reserve = payload.models.find(model => model.slug === `main/${reserveModel}`); + expect(reserve !== undefined && reserve.visibility !== "hide").toBe(expected); + if (disabledModels.length > 0) { + expect(reserve).toBeDefined(); + expect(reserve!.visibility).toBe("hide"); + } + expect(slugs).not.toContain(`side/${reserveModel}`); + expect(slugs).not.toContain(reserveModel); + } finally { + await stopPoolRetryHarness(harness); + } + } + }); });