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 9a781a4f1..8244cd11d 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -24,6 +24,45 @@ gpt-5.6-sol # openai (Pool or Direct option) openai-apikey/gpt-5.6-sol # API key ``` +When multiple Codex accounts are logged in, turn on **Show each Codex account separately in the +model picker** under **Codex Auth** to target one account for a conversation without logging out. +You can also configure `codexAccountNamespaces` and run `ocx sync`. The picker then shows a separate +copy of every GPT model for every configured account: + +```text +main/gpt-5.6-sol # exact main/Desktop account +side/gpt-5.6-sol # exact added account +``` + +`main` identifies the built-in Codex login. `side` is a public selector that maps privately to one +stored account; it is not the account's internal ID or an account type. Dashboard-generated +selectors use the account's explicit user-owned alias, or a stable privacy-safe `pXXXXXX` label when +no alias is set. They never derive from the stored account ID or email. A numeric suffix avoids +existing provider or combo prefixes. The equivalent config is: + +```json +{ + "codexAccountNamespaces": { "main": "main", "side": "added-account-id" } +} +``` + +The object keys are public selectors; the values are private stored account IDs used only for exact +routing. Existing and custom selectors are never regenerated. Plain `gpt-*` ids remain valid for +saved threads and config, and continue to use the provider's Pool/Direct behavior. Omitting the map +keeps the existing picker unchanged. Turning the dashboard setting off hides account-specific rows +but preserves their bindings and explicit selectors. The **Codex Auth** dashboard refreshes the +catalog automatically after changing the toggle. +Deleting an added account removes its picker rows but retains the selector binding so saved +conversations fail closed instead of falling through. Re-adding the same stored account restores +those rows. + +An account-qualified native selector still routes through the canonical `openai` provider and pins +the request to its exact Codex account; it never falls through to Pool/Direct account selection. +Missing credentials, cooldown, or reauthentication fail the request. Bare models retain normal +Pool/Direct behavior, and future native models added to opencodex's supported catalog automatically +receive the configured prefixes on the next sync. See the +[configuration reference](/reference/configuration/#account-qualified-native-models). + Fresh installs and configs with no saved mode default to Pool. Current configs use marker 2 and retain the shipped v1 source at `~/.opencodex/config.json.pre-openai-tiers-v2.bak`; restore it with: diff --git a/docs-site/src/content/docs/guides/model-routing.md b/docs-site/src/content/docs/guides/model-routing.md index 8b37525af..01d22a161 100644 --- a/docs-site/src/content/docs/guides/model-routing.md +++ b/docs-site/src/content/docs/guides/model-routing.md @@ -11,9 +11,18 @@ Pool(default, main plus added accounts) or Direct(current caller/main bearer) wi model id. `openai-apikey/` explicitly selects API-key transport. The two credential routes do not fall through to one another. +Configured `codexAccountNamespaces` are checked first. A selector such as `side/gpt-5.6-sol` +resolves to the canonical `openai` provider plus one immutable account id. It bypasses pool +selection and fails closed instead of changing accounts. Account namespace keys cannot collide with +configured provider names. + ## Precedence -1. **Explicit `provider/model`** — if the id contains `/` and the part before it is the name of a +1. **Account-qualified native model** — if the prefix is configured in + `codexAccountNamespaces`, the remainder must be a bare native OpenAI model id and the exact + configured account is used. + +2. **Explicit `provider/model`** — if the id contains `/` and the part before it is the name of a configured provider, that provider is used and the id is stripped to the part after the slash. ```text @@ -25,10 +34,10 @@ do not fall through to one another. This is the unambiguous form, and the one Codex's model picker uses for routed models. If the named provider is disabled, this explicit form throws instead of routing. -2. **A provider's `defaultModel`** — if any provider's `defaultModel` equals the id, that provider +3. **A provider's `defaultModel`** — if any provider's `defaultModel` equals the id, that provider is used (id passed through unchanged). -3. **Built-in prefix patterns** — the id is matched against known model-family prefixes, then routed +4. **Built-in prefix patterns** — the id is matched against known model-family prefixes, then routed to a configured provider of that name (or name-prefix): | Prefixes | Provider | @@ -40,11 +49,11 @@ do not fall through to one another. This matcher is name-based and, unlike the `defaultModel` / `models[]` scans, currently does not filter a matching provider whose `disabled` flag is true. -4. **A provider's `models[]`** — if no prefix rule won and an active provider lists the id in its +5. **A provider's `models[]`** — if no prefix rule won and an active provider lists the id in its `models[]`, that provider is used. This order matters: with an OpenAI-named provider configured, a bare `gpt-*` id reaches it before another provider's `models[]` claim. -5. **Default provider** — if nothing matched, the id is sent to `config.defaultProvider` unchanged. +6. **Default provider** — if nothing matched, the id is sent to `config.defaultProvider` unchanged. (If no default provider is configured, or it is disabled, routing throws.) ## API keys and environment variables 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 b041abdcf..3e8bdc8dd 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 @@ -12,6 +12,29 @@ OpenAI ID は 2 種類に固定されます。bare native ID は `codexAccountMo ID は変わりません。API GPT-5.6 は context 1,050,000 / max input 922,000 で、 `*-pro` ピッカー ID は公開状態を維持しつつ wire でベースモデル + `reasoning.mode: "pro"` になります。 +複数の Codex アカウントにログインしている場合は、**Codex Auth** の **各 Codex アカウントを +モデル選択に個別表示**を有効にすると、ログアウトせずに会話で使うアカウントを明示できます。 +各 GPT モデルはアカウントごとに別の項目として表示されます。 + +```text +main/gpt-5.6-sol # 組み込みの Codex ログインに固定 +side/gpt-5.6-sol # 公開セレクター side に対応する追加アカウントに固定 +``` + +`main` は組み込みログインを表します。`side` は保存済みアカウント 1 件に非公開で対応する公開 +セレクターであり、内部アカウント ID やアカウント種別ではありません。ダッシュボードが生成する +セレクターには、ユーザーが明示した表示名を使います。表示名がなければ、安定したプライバシー保護 +ラベル `pXXXXXX` を使い、保存済みアカウント ID やメールアドレスからは生成しません。既存または +カスタムのセレクターは変更されず、名前が重なる場合だけ数字が付きます。アカウント指定の native +セレクターは引き続き canonical `openai` プロバイダーを通り、選択したアカウントに固定されます。 +Pool/Direct のアカウント選択やフォールバックは行わず、選択したアカウントが利用できない場合は +失敗します。プレフィックスなしの `gpt-*` は従来の Pool/Direct 動作を維持します。設定を無効にしても +項目が非表示になるだけで、保存済みのバインドは削除されません。追加アカウントを削除するとその項目は +消えますが、保存済みの選択が別アカウントへ流れないようバインドは残ります。同じ保存済みアカウントを +再追加すると項目が戻ります。 +手動設定と `ocx sync` については +[設定リファレンス](/ja/reference/configuration/)を参照してください。 + ## 統合経路 `ocx init`、`ocx start`、`ocx sync` は解決された `CODEX_HOME` の下のファイルを合わせます。 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 3dd14431c..542051335 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 @@ -12,6 +12,27 @@ OpenAI id는 두 가지로 고정됩니다. bare native id는 `codexAccountMode` id는 변하지 않습니다. API GPT-5.6은 context 1,050,000 / max input 922,000이고, `*-pro` picker id는 공개 상태를 유지하면서 wire에서 base 모델 + `reasoning.mode: "pro"`가 됩니다. +여러 Codex 계정에 로그인한 경우 **Codex Auth**에서 **각 Codex 계정을 모델 선택기에 별도로 표시**를 +켜면 로그아웃하지 않고도 대화에 사용할 계정을 명시적으로 고를 수 있습니다. 각 GPT 모델은 계정마다 +별도 항목으로 표시됩니다. + +```text +main/gpt-5.6-sol # 내장 Codex 로그인에 고정 +side/gpt-5.6-sol # 공개 선택기 side에 연결된 추가 계정에 고정 +``` + +`main`은 내장 로그인을 뜻합니다. `side`는 저장된 계정 하나에 비공개로 연결되는 공개 선택기이며 내부 +계정 ID나 계정 유형이 아닙니다. 대시보드가 선택기를 만들 때는 사용자가 명시한 표시 이름을 사용하고, +표시 이름이 없으면 안정적인 개인정보 보호 라벨 `pXXXXXX`를 사용합니다. 저장된 계정 ID나 이메일에서 +선택기를 만들지 않습니다. 기존 선택기와 사용자 지정 선택기는 바뀌지 않으며 이름이 겹칠 때만 숫자 +접미사가 붙습니다. 계정 지정 native 선택기는 계속 canonical `openai` 프로바이더를 통해 라우팅되고 +정확히 선택한 계정에 고정됩니다. Pool/Direct 계정 선택이나 폴백을 사용하지 않으며, 선택한 계정을 사용할 +수 없으면 요청이 실패합니다. 접두사 없는 `gpt-*`는 기존 Pool/Direct 동작을 유지합니다. 설정을 꺼도 +항목만 숨겨지고 저장된 바인딩은 삭제되지 않습니다. 추가 계정을 삭제하면 선택기 항목은 사라지지만 저장된 +선택이 다른 계정으로 넘어가지 않도록 바인딩은 남습니다. 같은 저장 계정을 다시 추가하면 항목이 복원됩니다. +수동 설정과 `ocx sync`는 +[설정 레퍼런스](/ko/reference/configuration/)를 참고하세요. + ## 통합 경로 `ocx init`, `ocx start`, `ocx sync`는 해석된 `CODEX_HOME` 아래의 파일을 맞춥니다. diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 9d865ca0f..0a2a0b244 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -52,6 +52,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | +| `codexAccountNamespaces?` | `Record` | `{}` | Optional public picker selector to private exact Codex account id map. Use `"main"` for the current Codex Desktop/main login. Keys are catalog- and log-visible; values remain server-side. When picker visibility is enabled and at least one binding is available, account-qualified native rows replace plain GPT rows in the picker and fail closed instead of switching accounts. If no binding is available, plain rows remain visible. Plain model ids remain routable. Selector keys cannot collide with provider ids. | +| `codexAccountPickerEnabled?` | `boolean` | inferred | Picker-visibility override. A non-empty namespace map is visible when this is omitted. The dashboard writes `false` to hide account-specific rows without deleting their bindings. | | `activeCodexAccountId?` | `string` | — | Pool account used for the next new Codex thread. Existing thread affinities keep their original account. | | `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient upstream failures before future new sessions fail over to another eligible pool account. Set `0` to disable failure failover. | @@ -77,6 +79,68 @@ credential store. Existing thread ids keep account affinity, while new sessions on quota, cooldown, and health. ::: +### Account-qualified native models + +Use `codexAccountNamespaces` when account choice must be visible and intentional in the model picker: + +```json +{ + "codexAccountNamespaces": { + "main": "main", + "side": "added-account-id" + } +} +``` + +Each object key is a public, user-owned picker selector; each value is the private stored account ID +used for exact routing. `side` may be any valid selector chosen by the user. The value can be found +locally with `ocx account list openai`, but it is never copied into model catalog rows, errors, or +provider log labels. After `ocx sync`, Codex exposes `main/gpt-*` and `side/gpt-*` copies of every +available supported native model. When a later opencodex update adds support for another native +model, the next catalog sync gives it the same selectors without adding the model separately to this +map. + +The dashboard's **Codex Auth** page exposes **Show each Codex account separately in the model +picker** as the single opt-in. Turning it on creates a prefix based on `main` for the main/Desktop +login. For an added account, it uses the explicit user-owned display alias when one is set; otherwise +it generates a stable privacy-safe selector in the form `pXXXXXX`. It never derives a public selector +from the stored account ID or email. A numeric suffix resolves provider or combo-prefix collisions. +These names identify accounts; they do not represent built-in account types. Existing and custom +selectors are never regenerated. Turning the setting off hides the account-qualified rows while +preserving their configured bindings, so turning it back on restores the same picker identifiers. +Stored account IDs, upstream ChatGPT account IDs, emails, and credentials stay server-side. Picker +selectors stay stable if an account's display alias is renamed; accounts added later receive a new +privacy-safe selector automatically while the feature remains enabled. + +Deleting an added account hides its rows but keeps its namespace binding as a fail-closed marker for +saved selectors. Re-adding the same stored account restores the existing selector instead of creating +a new one. + +When enabled, plain native rows are hidden from the picker and replaced by readable account-labeled +entries such as `Main / 5.6 Sol` and `Side / 5.6 Sol`. Plain model ids are not removed or rerouted: +saved threads and configuration that use `gpt-*` continue to follow Pool/Direct behavior. Because +Codex exposes a flat picker rather than section metadata, the account-specific pairs stay together +ahead of ordinary routed rows; explicitly featured subagent models retain their higher priority. + +These selectors do not change `activeCodexAccountId`. They bypass quota auto-switch, transient- +failure failover, affinity rebinding, and unsupported-model retry. If the exact account is missing, +cooling down, or needs reauthentication, the request fails instead of using another account. Bare +`gpt-*` models keep the configured Pool/Direct behavior. + +Account-qualified native selectors still route through the canonical `openai` provider and pin the +request to the selected Codex account. They never fall through to Pool/Direct account selection. + +Account-qualified ids remain explicitly routable while their picker rows are hidden. This preserves +saved selections; the visibility toggle controls discovery, not the fail-closed account binding. + +Account binding follows the model selector. A bare `subagentModels` entry is projected onto its +visible account-specific rows so Codex can advertise those choices; the +client's five-model subagent limit still applies. Use qualified values in `subagentModels`, +`injectionModel`, and `shadowCallIntercept.model` when those calls must use the same account. A +child explicitly launched with a bare model uses ordinary Pool/Direct routing. Standalone client- +side image and alpha-search relays do not reliably carry the selected chat model, so they continue +to use the provider's global Pool/Direct account behavior. + ### claudeCode (OcxClaudeCodeConfig) Claude Code inbound settings consumed by the `/v1/messages` surface, the `ocx claude` 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 7ac97ef49..31ece618f 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 @@ -24,6 +24,29 @@ gpt-5.6-sol # openai (Pool or Direct option) openai-apikey/gpt-5.6-sol # API key ``` +Если выполнен вход в несколько аккаунтов Codex, включите **Показывать каждый аккаунт Codex +отдельно в списке моделей** на странице **Codex Auth**. Тогда аккаунт для диалога можно выбрать +явно, не выходя из остальных аккаунтов, а каждая GPT-модель показывается отдельно для каждого из них: + +```text +main/gpt-5.6-sol # только встроенный вход Codex +side/gpt-5.6-sol # только добавленный аккаунт с публичным селектором side +``` + +`main` обозначает встроенный вход. `side` — публичный селектор, который приватно сопоставлен одному +сохранённому аккаунту; это не внутренний ID и не тип аккаунта. Для автоматически созданного селектора +используется явно заданное пользователем отображаемое имя, а без него — стабильная безопасная метка +`pXXXXXX`. Селектор никогда не создаётся из сохранённого ID аккаунта или адреса электронной почты. +Существующие и пользовательские селекторы не переименовываются; при конфликте добавляется числовой +суффикс. Нативный селектор с аккаунтом по-прежнему маршрутизируется через канонический провайдер +`openai`, закрепляется за точным аккаунтом и не использует выбор или резервирование Pool/Direct. Если +указанный аккаунт недоступен, запрос завершается ошибкой. ID `gpt-*` без префикса сохраняют обычное +поведение Pool/Direct. При отключении настройки строки только скрываются, а сохранённые привязки не +удаляются. При удалении добавленного аккаунта его строки исчезают, но привязка остаётся, чтобы +сохранённый выбор не перешёл на другой аккаунт. Повторное добавление того же сохранённого аккаунта +восстанавливает строки. Ручная настройка и `ocx sync` описаны в +[справочнике конфигурации](/ru/reference/configuration/). + Свежие установки и конфигурации без сохранённого режима по умолчанию используют Pool. Текущие конфигурации используют маркер 2 и сохраняют поставлявшийся v1-вариант в `~/.opencodex/config.json.pre-openai-tiers-v2.bak`; восстановить его можно так: 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 15f4c90e3..0fb6a8e20 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 @@ -12,6 +12,24 @@ OpenAI 身份固定为两种:bare native id 是由 `codexAccountMode` 控制 P context / 922,000 max input;`*-pro` picker id 保持公开身份,线上使用 base 模型加 `reasoning.mode: "pro"`。 +如果已登录多个 Codex 账户,可在 **Codex Auth** 中启用 **在模型选择器中分别显示每个 Codex 账户**。 +这样无需退出任何账户,就能明确选择对话使用的账户;每个 GPT 模型都会为每个账户显示单独条目: + +```text +main/gpt-5.6-sol # 仅使用内置 Codex 登录 +side/gpt-5.6-sol # 仅使用公开选择器为 side 的已添加账户 +``` + +`main` 表示内置登录。`side` 是一个公开选择器,会在内部映射到一个已存储账户;它不是内部账户 ID, +也不代表账户类型。由仪表盘生成选择器时,会优先使用用户明确设置的显示名称;如果未设置显示名称, +则使用稳定且保护隐私的 `pXXXXXX` 标签。选择器绝不会从存储的账户 ID 或电子邮件生成。现有选择器和 +自定义选择器不会被重命名;如果与现有路由名称冲突,则添加数字后缀。账户专属的原生选择器仍会通过 +规范的 `openai` provider 路由,并固定使用所选账户,不会转入 Pool/Direct 账户选择或回退。如果指定 +账户不可用,请求会直接失败。不带前缀的 `gpt-*` 会保留原有 Pool/Direct 行为。关闭设置只会隐藏这些 +条目,不会删除已保存的绑定。删除已添加账户后,其选择器条目会消失,但绑定会保留,确保已保存的 +选择不会转到其他账户。重新添加同一已存储账户会恢复这些条目。 +手动配置与 `ocx sync` 详见[配置参考](/zh-cn/reference/configuration/)。 + ## 集成路径 `ocx init`、`ocx start` 和 `ocx sync` 会保持解析后的 `CODEX_HOME` 目录下这些文件一致: diff --git a/gui/src/components/AddCodexAccountModal.tsx b/gui/src/components/AddCodexAccountModal.tsx index fa2e60d98..82c2d7708 100644 --- a/gui/src/components/AddCodexAccountModal.tsx +++ b/gui/src/components/AddCodexAccountModal.tsx @@ -7,7 +7,7 @@ export default function AddCodexAccountModal({ }: { apiBase: string; onClose: () => void; - onAdded: () => void; + onAdded: (catalogRefreshPending?: boolean) => void; reauthAccountId?: string; }) { const t = useT(); @@ -160,7 +160,11 @@ export default function AddCodexAccountModal({ : `${apiBase}/api/codex-auth/login-status`; pollRef.current = setInterval(async () => { try { - const st = await fetch(statusUrl).then(r => r.json()) as { status: string; error?: string }; + const st = await fetch(statusUrl).then(r => r.json()) as { + status: string; + error?: string; + catalogRefreshPending?: boolean; + }; if (!aliveRef.current) return; setPollErrorStreak(0); if (manualCodeStateRef.current === "waiting") { @@ -176,7 +180,7 @@ export default function AddCodexAccountModal({ flowRef.current = null; setFlowId(null); if (!aliveRef.current) return; - onAddedRef.current(); + onAddedRef.current(st.catalogRefreshPending === true); onCloseRef.current(); } else if (st.status === "error" || st.status === "expired") { stopPolling(); diff --git a/gui/src/components/CodexAccountPickerSetting.tsx b/gui/src/components/CodexAccountPickerSetting.tsx new file mode 100644 index 000000000..728a8ff2d --- /dev/null +++ b/gui/src/components/CodexAccountPickerSetting.tsx @@ -0,0 +1,57 @@ +import { useT } from "../i18n"; + +export interface CodexAccountPickerSettingProps { + enabled: boolean | null; + saving: boolean; + feedback: { tone: "ok" | "err"; message: string } | null; + onToggle(): void; +} + +export function CodexAccountPickerSetting({ + enabled, + saving, + feedback, + onToggle, +}: CodexAccountPickerSettingProps) { + const t = useT(); + if (enabled === null) return null; + + return ( +
+
+
+ {t("codexAuth.namespacesTitle")} +

+ {t(enabled ? "codexAuth.namespacesOnDesc" : "codexAuth.namespacesOffDesc")} +

+ {enabled && ( +

+ {t("codexAuth.namespacesCompatibilityNote")} +

+ )} +
+ +
+ {feedback && ( +

+ {feedback.message} +

+ )} +
+ ); +} + +export default CodexAccountPickerSetting; diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index 1a98b89c2..31264fe80 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -102,10 +102,10 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban setReauthId(null); }, []); - const handleAccountAdded = useCallback(() => { + const handleAccountAdded = useCallback((catalogRefreshPending = false) => { void controller.syncAfterAccountAdded(); - setToast(t("codexAuth.accountAdded")); - setToastError(false); + setToast(t(catalogRefreshPending ? "codexAuth.catalogRefreshPending" : "codexAuth.accountAdded")); + setToastError(catalogRefreshPending); setTimeout(() => setToast(""), 5000); closeAddModal(); }, [closeAddModal, controller, t]); @@ -147,6 +147,10 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban setToast(t("codexAuth.removeFailed")); setToastError(true); setTimeout(() => setToast(""), 5000); + } else if (result.catalogRefreshPending) { + setToast(t("codexAuth.catalogRefreshPending")); + setToastError(true); + setTimeout(() => setToast(""), 5000); } }; diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 7ad680159..5911a4aaf 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -56,7 +56,7 @@ export interface CodexAccountPoolController { load(refreshQuota?: boolean): Promise; switchAccount(id: string | null): Promise>; saveAlias(id: string, alias: string): Promise; - removeAccount(id: string): Promise; + removeAccount(id: string): Promise>; syncAfterAccountAdded(): Promise; pauseRefresh(): PauseToken; @@ -232,8 +232,9 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou { method: "DELETE" }, ); if (!response.ok) return { ok: false, reason: "request" } as const; + const result = await response.json().catch(() => ({})) as { catalogRefreshPending?: boolean }; await load(); - return { ok: true } as const; + return { ok: true, catalogRefreshPending: result.catalogRefreshPending === true } as const; } catch { return { ok: false, reason: "request" } as const; } diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 12570d1e7..aaa041d40 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -609,6 +609,12 @@ export const de = { "codexAuth.openaiMissing": "Der integrierte OpenAI-Anbieter ist nicht konfiguriert.", "codexAuth.openaiDisabled": "Der integrierte OpenAI-Anbieter ist deaktiviert.", "codexAuth.openProviders": "Anbieter öffnen", + "codexAuth.namespacesTitle": "Jedes Codex-Konto separat in der Modellauswahl anzeigen", + "codexAuth.namespacesOnDesc": "Für jedes verfügbare Codex-Konto erscheint jedes GPT-Modell als eigener Eintrag. Main kennzeichnet den integrierten Login. Jede öffentliche Bezeichnung in der Modellauswahl wird intern genau einem gespeicherten Konto zugeordnet. Hinzugefügte Konten verwenden ihren selbst gewählten Anzeigenamen oder, falls keiner gesetzt ist, eine stabile datenschutzfreundliche Bezeichnung im Format pXXXXXX; gespeicherte Konto-IDs und E-Mail-Adressen werden nie verwendet. Bestehende und benutzerdefinierte Bezeichnungen bleiben unverändert. Die Auswahl bindet die Unterhaltung genau an dieses Konto; OpenCodex wechselt nicht und fällt nicht auf ein anderes Konto zurück.", + "codexAuth.namespacesOffDesc": "Aktiviere dies, wenn du mehrere Codex-Konten hast und ohne Abmeldung genau auswählen möchtest, welches Konto eine Unterhaltung verwendet. Beim Ausschalten werden nur diese Einträge ausgeblendet; Konten werden nicht entfernt.", + "codexAuth.namespacesCompatibilityNote": "Dies ersetzt die normalen GPT-Einträge in der Modellauswahl durch kontospezifische Optionen. Bestehende Unterhaltungen, benutzerdefinierte Kontobezeichnungen und gespeicherte Modellauswahlen behalten ihr aktuelles Routing; reine GPT-Modell-IDs behalten ihr Pool- oder Direktverhalten.", + "codexAuth.namespacesSaved": "Kontospezifische Einträge aktualisiert. Öffne Codex erneut, falls die Liste veraltet ist.", + "codexAuth.namespacesSaveFailed": "Die Modellauswahl konnte nicht aktualisiert werden. Die vorherige Einstellung bleibt erhalten.", "codexAuth.add": "Hinzufügen", "codexAuth.refreshQuota": "Kontingente aktualisieren", "codexAuth.refreshingQuota": "Aktualisiere…", @@ -657,6 +663,7 @@ export const de = { "codexAuth.importMissingTokens": "access_token oder refresh_token fehlen in JSON", "codexAuth.importMissingId": "Konto-ID ist erforderlich", "codexAuth.accountAdded": "Konto zum Pool hinzugefügt", + "codexAuth.catalogRefreshPending": "Die Kontoänderung wurde gespeichert, aber die Modellauswahl konnte nicht aktualisiert werden. Führe `ocx sync` aus, um es erneut zu versuchen.", "codexAuth.addPickDesc": "Melde dich mit einem anderen ChatGPT-Konto an, um es zum Pool hinzuzufügen.", "codexAuth.oauthLogin": "OAuth-Login", "codexAuth.oauthDesc": "Öffnet ChatGPT-Login im Browser", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 4084f06e7..a48f29107 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -909,6 +909,12 @@ export const en = { "codexAuth.openaiMissing": "The built-in OpenAI provider is not configured.", "codexAuth.openaiDisabled": "The built-in OpenAI provider is disabled.", "codexAuth.openProviders": "Open Providers", + "codexAuth.namespacesTitle": "Show each Codex account separately in the model picker", + "codexAuth.namespacesOnDesc": "Each available Codex account gets a separate entry for every GPT model. Main identifies the built-in login. Each public picker label maps privately to one stored account. Added accounts use their user-owned alias, or a stable privacy-safe pXXXXXX label when no alias is set; stored account IDs and emails are never used. Existing and custom labels remain unchanged. Selecting one pins the conversation to that exact account; OpenCodex will not switch or fall back to another account.", + "codexAuth.namespacesOffDesc": "Turn this on when you have multiple Codex accounts and want to choose exactly which account a conversation uses without logging out. Turning it off only hides these picker entries; it does not remove any account.", + "codexAuth.namespacesCompatibilityNote": "This replaces the regular GPT picker entries with explicit account-specific choices. Existing conversations, custom account labels, and saved model selections keep their current routing; bare GPT model IDs keep their Pool or Direct behavior.", + "codexAuth.namespacesSaved": "Account-specific picker entries updated. Reopen Codex if the current model list is stale.", + "codexAuth.namespacesSaveFailed": "The model picker could not be updated. The previous setting is unchanged.", "codexAuth.add": "Add", "codexAuth.refreshQuota": "Refresh quotas", "codexAuth.refreshingQuota": "Refreshing...", @@ -959,6 +965,7 @@ export const en = { "codexAuth.importMissingTokens": "Missing access_token or refresh_token in JSON", "codexAuth.importMissingId": "Account ID is required", "codexAuth.accountAdded": "Account added to pool", + "codexAuth.catalogRefreshPending": "The account change was saved, but the model picker could not refresh. Run `ocx sync` to retry.", "codexAuth.addPickDesc": "Login with another ChatGPT account to add it to the pool.", "codexAuth.oauthLogin": "OAuth Login", "codexAuth.oauthDesc": "Opens ChatGPT login in browser", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 7218014b2..11c2c5c9d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -863,6 +863,12 @@ export const ja: Record = { "codexAuth.openaiMissing": "組み込みの OpenAI プロバイダーが設定されていません。", "codexAuth.openaiDisabled": "組み込みの OpenAI プロバイダーが無効です。", "codexAuth.openProviders": "プロバイダーを開く", + "codexAuth.namespacesTitle": "各 Codex アカウントをモデル選択に個別表示", + "codexAuth.namespacesOnDesc": "利用可能な Codex アカウントごとに各 GPT モデルが個別に表示されます。Main は組み込みログインを表し、公開される各ピッカーラベルは保存済みアカウント 1 件に非公開で対応します。追加アカウントにはユーザーが指定した表示名を使い、表示名がなければ安定したプライバシー保護ラベル pXXXXXX を使います。保存済みアカウント ID やメールアドレスは使用しません。既存またはカスタムのラベルは変更されません。選択した会話はそのアカウントに固定され、OpenCodex が別のアカウントへ切り替えたりフォールバックしたりすることはありません。", + "codexAuth.namespacesOffDesc": "複数の Codex アカウントから、ログアウトせずに会話で使うアカウントを明示的に選びたい場合に有効にします。無効にしてもこれらの項目が非表示になるだけで、アカウントは削除されません。", + "codexAuth.namespacesCompatibilityNote": "モデル選択の通常の GPT 項目を、アカウント別の明示的な項目に置き換えます。既存の会話、カスタムのアカウントラベル、保存済みのモデル選択は現在のルーティングを維持し、プレフィックスなしの GPT モデル ID は従来のプールまたは直接動作を維持します。", + "codexAuth.namespacesSaved": "アカウント別のモデル選択項目を更新しました。表示が古い場合は Codex を開き直してください。", + "codexAuth.namespacesSaveFailed": "モデル選択を更新できませんでした。以前の設定は維持されます。", "codexAuth.add": "追加", "codexAuth.refreshQuota": "クォータを更新", "codexAuth.refreshingQuota": "更新中...", @@ -913,6 +919,7 @@ export const ja: Record = { "codexAuth.importMissingTokens": "JSON に access_token または refresh_token がありません", "codexAuth.importMissingId": "アカウント ID は必須です", "codexAuth.accountAdded": "アカウントをプールに追加しました", + "codexAuth.catalogRefreshPending": "アカウントの変更は保存されましたが、モデルピッカーを更新できませんでした。`ocx sync` を実行して再試行してください。", "codexAuth.addPickDesc": "別の ChatGPT アカウントでログインしてプールに追加します。", "codexAuth.oauthLogin": "OAuth ログイン", "codexAuth.oauthDesc": "ブラウザで ChatGPT ログインを開きます", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index df4ef7223..6f8a7b094 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -628,6 +628,12 @@ export const ko: Record = { "codexAuth.openaiMissing": "내장 OpenAI 프로바이더가 설정되지 않았습니다.", "codexAuth.openaiDisabled": "내장 OpenAI 프로바이더가 비활성화되어 있습니다.", "codexAuth.openProviders": "프로바이더 열기", + "codexAuth.namespacesTitle": "각 Codex 계정을 모델 선택기에 별도로 표시", + "codexAuth.namespacesOnDesc": "사용 가능한 Codex 계정마다 각 GPT 모델이 별도 항목으로 표시됩니다. Main은 기본 제공 로그인을 뜻하며 공개 선택기 라벨은 저장된 계정 하나에 비공개로 연결됩니다. 추가 계정은 사용자가 정한 표시 이름을 사용하고, 표시 이름이 없으면 안정적인 개인정보 보호 라벨 pXXXXXX를 사용합니다. 저장된 계정 ID와 이메일은 사용하지 않습니다. 기존 라벨과 사용자 지정 라벨은 바뀌지 않습니다. 선택한 대화는 해당 계정에 고정되고 OpenCodex는 다른 계정으로 전환하거나 대체하지 않습니다.", + "codexAuth.namespacesOffDesc": "여러 Codex 계정 중 대화에 사용할 계정을 로그아웃 없이 명시적으로 선택하려면 켜세요. 꺼도 이 항목만 숨겨지며 계정은 삭제되지 않습니다.", + "codexAuth.namespacesCompatibilityNote": "모델 선택기의 일반 GPT 항목을 명시적인 계정별 항목으로 바꿉니다. 기존 대화, 사용자 지정 계정 라벨, 저장된 모델 선택은 현재 라우팅을 유지하고, 접두사 없는 GPT 모델 ID는 기존 풀 또는 직접 동작을 유지합니다.", + "codexAuth.namespacesSaved": "계정별 모델 선택 항목이 업데이트되었습니다. 목록이 오래된 경우 Codex를 다시 여세요.", + "codexAuth.namespacesSaveFailed": "모델 선택기를 업데이트하지 못했습니다. 이전 설정은 유지됩니다.", "codexAuth.add": "추가", "codexAuth.refreshQuota": "할당량 새로고침", "codexAuth.refreshingQuota": "새로고침 중...", @@ -676,6 +682,7 @@ export const ko: Record = { "codexAuth.importMissingTokens": "JSON에 access_token 또는 refresh_token이 없습니다", "codexAuth.importMissingId": "계정 ID를 입력하세요", "codexAuth.accountAdded": "풀에 계정이 추가되었습니다", + "codexAuth.catalogRefreshPending": "계정 변경은 저장되었지만 모델 선택기를 새로 고치지 못했습니다. `ocx sync`를 실행해 다시 시도하세요.", "codexAuth.addPickDesc": "다른 ChatGPT 계정으로 로그인하여 풀에 추가하세요.", "codexAuth.oauthLogin": "OAuth 로그인", "codexAuth.oauthDesc": "브라우저에서 ChatGPT 로그인 열기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 9504c35f1..b70b48780 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -908,6 +908,12 @@ export const ru: Record = { "codexAuth.openaiMissing": "Встроенный провайдер OpenAI не настроен.", "codexAuth.openaiDisabled": "Встроенный провайдер OpenAI отключён.", "codexAuth.openProviders": "Открыть провайдеров", + "codexAuth.namespacesTitle": "Показывать каждый аккаунт Codex отдельно в списке моделей", + "codexAuth.namespacesOnDesc": "Для каждого доступного аккаунта Codex каждая GPT-модель показывается отдельно. Main обозначает встроенный вход, а каждая публичная метка в селекторе приватно сопоставляется одному сохранённому аккаунту. Для добавленного аккаунта используется заданное пользователем отображаемое имя, а без него — стабильная безопасная метка вида pXXXXXX. Сохранённые ID аккаунтов и адреса электронной почты не используются. Существующие и пользовательские метки не изменяются. Выбранный диалог закрепляется за этим аккаунтом; OpenCodex не переключается и не использует другой аккаунт как резервный.", + "codexAuth.namespacesOffDesc": "Включите, если у вас несколько аккаунтов Codex и вы хотите явно выбрать аккаунт для диалога без выхода из системы. При отключении эти строки только скрываются; аккаунты не удаляются.", + "codexAuth.namespacesCompatibilityNote": "Обычные строки GPT в списке моделей заменяются явными вариантами для каждого аккаунта. Существующие диалоги, пользовательские метки аккаунтов и сохранённый выбор модели сохраняют текущее направление, а ID GPT без префикса — режим Пул или Прямой.", + "codexAuth.namespacesSaved": "Отображение аккаунтов в списке моделей обновлено. Если список устарел, откройте Codex заново.", + "codexAuth.namespacesSaveFailed": "Не удалось обновить список моделей. Предыдущая настройка сохранена.", "codexAuth.add": "Добавить", "codexAuth.refreshQuota": "Обновить квоты", "codexAuth.refreshingQuota": "Обновление...", @@ -958,6 +964,7 @@ export const ru: Record = { "codexAuth.importMissingTokens": "В JSON отсутствует access_token или refresh_token", "codexAuth.importMissingId": "Укажите ID аккаунта", "codexAuth.accountAdded": "Аккаунт добавлен в пул", + "codexAuth.catalogRefreshPending": "Изменение аккаунта сохранено, но список моделей не удалось обновить. Выполните `ocx sync`, чтобы повторить попытку.", "codexAuth.addPickDesc": "Войдите в другой аккаунт ChatGPT, чтобы добавить его в пул.", "codexAuth.oauthLogin": "Вход через OAuth", "codexAuth.oauthDesc": "Открывает вход ChatGPT в браузере", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index eccf72b1f..e0340d9e1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -628,6 +628,12 @@ export const zh: Record = { "codexAuth.openaiMissing": "未配置内置 OpenAI 提供方。", "codexAuth.openaiDisabled": "内置 OpenAI 提供方已禁用。", "codexAuth.openProviders": "打开提供商", + "codexAuth.namespacesTitle": "在模型选择器中分别显示每个 Codex 账户", + "codexAuth.namespacesOnDesc": "每个可用的 Codex 账户都会单独显示一份各 GPT 模型。Main 表示内置登录;每个公开的选择器标签都会在内部映射到一个已存储账户。已添加账户会使用用户指定的显示名称;如果未设置显示名称,则使用稳定且保护隐私的 pXXXXXX 标签。存储的账户 ID 和电子邮件不会被用作标签。现有标签和自定义标签保持不变。选择后,对话会固定使用该账户;OpenCodex 不会切换或回退到其他账户。", + "codexAuth.namespacesOffDesc": "如果你有多个 Codex 账户,并希望无需退出登录即可明确选择对话使用的账户,请开启此项。关闭后只会隐藏这些选项,不会删除任何账户。", + "codexAuth.namespacesCompatibilityNote": "这会用明确的账户专属选项替换模型选择器中的常规 GPT 项。现有对话、自定义账户标签和已保存的模型选择会保持当前路由,不带前缀的 GPT 模型 ID 会继续遵循原有的账户池或直接模式。", + "codexAuth.namespacesSaved": "模型选择器中的账户显示已更新。如果列表仍旧,请重新打开 Codex。", + "codexAuth.namespacesSaveFailed": "无法更新模型选择器。之前的设置保持不变。", "codexAuth.add": "添加", "codexAuth.refreshQuota": "刷新额度", "codexAuth.refreshingQuota": "刷新中...", @@ -676,6 +682,7 @@ export const zh: Record = { "codexAuth.importMissingTokens": "JSON 中缺少 access_token 或 refresh_token", "codexAuth.importMissingId": "请输入账号 ID", "codexAuth.accountAdded": "账号已添加到池中", + "codexAuth.catalogRefreshPending": "账号更改已保存,但模型选择器未能刷新。请运行 `ocx sync` 重试。", "codexAuth.addPickDesc": "使用另一个 ChatGPT 账号登录以添加到池中。", "codexAuth.oauthLogin": "OAuth 登录", "codexAuth.oauthDesc": "在浏览器中打开 ChatGPT 登录", diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index 4ee6d9a44..524ab9eda 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -1,6 +1,7 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useT } from "../i18n"; import CodexAccountPool from "../components/CodexAccountPool"; +import CodexAccountPickerSetting from "../components/CodexAccountPickerSetting"; import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state"; /** @@ -11,11 +12,22 @@ import { codexAccountModeState, type CodexAccountModeState } from "../codex-mult export default function CodexAuth({ apiBase }: { apiBase: string }) { const t = useT(); const [accountModeState, setAccountModeState] = useState(null); + const [namespacesEnabled, setNamespacesEnabled] = useState(null); + const [namespaceSaving, setNamespaceSaving] = useState(false); + const [namespaceFeedback, setNamespaceFeedback] = useState<{ tone: "ok" | "err"; message: string } | null>(null); + const namespaceMutationInFlightRef = useRef(false); const loadMode = useCallback(async () => { try { - const config = await fetch(`${apiBase}/api/config`).then(r => r.json()); + const response = await fetch(`${apiBase}/api/config`); + if (!response.ok) throw new Error("failed to load config"); + const config = await response.json() as { + codexAccountPickerEnabled?: boolean; + }; setAccountModeState(codexAccountModeState(config)); + if (!namespaceMutationInFlightRef.current) { + setNamespacesEnabled(config.codexAccountPickerEnabled === true); + } } catch { /* banner degrades to no badge */ } }, [apiBase]); @@ -25,6 +37,36 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { return () => { window.clearTimeout(timeout); window.clearInterval(iv); }; }, [loadMode]); + const saveNamespacesEnabled = async (next: boolean) => { + if (namespaceMutationInFlightRef.current || namespaceSaving + || namespacesEnabled === null || namespacesEnabled === next) return; + const previousEnabled = namespacesEnabled; + namespaceMutationInFlightRef.current = true; + setNamespaceSaving(true); + setNamespacesEnabled(next); + setNamespaceFeedback(null); + try { + const response = await fetch(`${apiBase}/api/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ codexAccountPickerEnabled: next }), + }); + const result = await response.json().catch(() => ({})) as { + error?: string; + codexAccountPickerEnabled?: boolean; + }; + if (!response.ok) throw new Error(result.error ?? "save failed"); + setNamespacesEnabled(result.codexAccountPickerEnabled === true); + setNamespaceFeedback({ tone: "ok", message: t("codexAuth.namespacesSaved") }); + } catch { + setNamespacesEnabled(previousEnabled); + setNamespaceFeedback({ tone: "err", message: t("codexAuth.namespacesSaveFailed") }); + } finally { + namespaceMutationInFlightRef.current = false; + setNamespaceSaving(false); + } + }; + const banner = (
@@ -50,6 +92,14 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { {t("codexAuth.openaiDisabled")} {t("codexAuth.openProviders")}

)} + { + if (namespacesEnabled !== null) void saveNamespacesEnabled(!namespacesEnabled); + }} + />
); diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 1281c57fc..9c4cf999e 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -448,9 +448,14 @@ export default function Providers({ apiBase }: { apiBase: string }) { setCodexLoginOpen(false)} - onAdded={() => { + onAdded={(catalogRefreshPending) => { setCodexLoginOpen(false); - notify(t("prov.loginOk", { provider: formatProviderDisplayName("openai"), cmd: "ocx sync" }), true); + notify( + catalogRefreshPending + ? t("codexAuth.catalogRefreshPending") + : t("prov.loginOk", { provider: formatProviderDisplayName("openai"), cmd: "ocx sync" }), + !catalogRefreshPending, + ); void fetchOauth(); void fetchProviderQuotas(true); bumpModelsRefresh(); diff --git a/gui/tests/codex-account-picker-setting.test.tsx b/gui/tests/codex-account-picker-setting.test.tsx new file mode 100644 index 000000000..6162322ff --- /dev/null +++ b/gui/tests/codex-account-picker-setting.test.tsx @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { CodexAccountPickerSetting } from "../src/components/CodexAccountPickerSetting"; +import { LanguageProvider } from "../src/i18n/provider"; + +let previousLanguage: unknown; + +beforeEach(() => { + previousLanguage = (globalThis.navigator as { language?: unknown } | undefined)?.language; + Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: "en-US" }); +}); + +afterEach(() => { + Object.defineProperty(globalThis.navigator, "language", { configurable: true, value: previousLanguage }); +}); + +function renderSetting( + enabled: boolean | null, + saving = false, + feedback: { tone: "ok" | "err"; message: string } | null = null, +): string { + return renderToStaticMarkup( + + {}} + /> + , + ); +} + +describe("Codex account picker setting", () => { + test("renders one intent-level off toggle without implementation terminology", () => { + const html = renderSetting(false); + expect(html).toContain("Show each Codex account separately in the model picker"); + expect(html).toContain("choose exactly which account a conversation uses without logging out"); + expect(html).toContain("does not remove any account"); + expect(html).toContain('aria-pressed="false"'); + expect(html).not.toContain("Bare + accounts"); + expect(html).not.toContain("Picker layout"); + }); + + test("explains strict binding and compatibility when enabled", () => { + const html = renderSetting(true); + expect(html).toContain("Main identifies the built-in login"); + expect(html).toContain("Each public picker label maps privately to one stored account"); + expect(html).toContain("user-owned alias"); + expect(html).toContain("stable privacy-safe pXXXXXX label"); + expect(html).toContain("stored account IDs and emails are never used"); + expect(html).toContain("Existing and custom labels remain unchanged"); + expect(html).toContain("pins the conversation to that exact account"); + expect(html).toContain("will not switch or fall back to another account"); + expect(html).toContain("replaces the regular GPT picker entries"); + expect(html).toContain("custom account labels"); + expect(html).toContain("saved model selections keep their current routing"); + expect(html).toContain("bare GPT model IDs keep their Pool or Direct behavior"); + expect(html).toContain('aria-pressed="true"'); + }); + + test("does not flash an actionable off state before hydration", () => { + expect(renderSetting(null)).toBe(""); + }); + + test("disables during writes and renders accessible feedback", () => { + expect(renderSetting(true, true)).toContain('disabled=""'); + expect(renderSetting(true, false, { tone: "ok", message: "Saved" })).toContain('role="status"'); + expect(renderSetting(true, false, { tone: "err", message: "Failed" })).toContain('role="alert"'); + }); +}); diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index 84a58bb0c..3d5b36ef5 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -22,6 +22,7 @@ let calls: string[] = []; let originalFetch: typeof globalThis.fetch; let accounts: unknown[] = []; let threshold = 80; +let deleteCatalogRefreshPending = false; beforeEach(() => { previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; @@ -36,12 +37,19 @@ beforeEach(() => { originalFetch = globalThis.fetch; calls = []; + deleteCatalogRefreshPending = false; accounts = [{ id: "a1", email: "account-one", isMain: true, hasCredential: true, quota: null }]; Object.defineProperty(globalThis, "fetch", { configurable: true, value: async (url: string, init?: RequestInit) => { const path = String(url).split("/api/")[1] ?? String(url); calls.push(`${init?.method ?? "GET"} ${path}`); + if (path.startsWith("codex-auth/accounts") && init?.method === "DELETE") { + return { + ok: true, + json: async () => ({ catalogRefreshPending: deleteCatalogRefreshPending }), + } as unknown as Response; + } if (path.startsWith("codex-auth/accounts")) { return { ok: true, json: async () => ({ accounts }) } as unknown as Response; } @@ -175,3 +183,14 @@ test("a mutation updates the one shared controller state", async () => { // The reconciliation reload landed on the same controller instance. expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1", "a2"]); }); + +test("account removal reports recoverable stale-catalog state to the presentation layer", async () => { + const seen = await mountController(); + deleteCatalogRefreshPending = true; + + let result: Awaited> | undefined; + await act(async () => { result = await seen.current!.removeAccount("a2"); }); + + expect(result).toEqual({ ok: true, catalogRefreshPending: true }); + expect(calls).toContain("DELETE codex-auth/accounts?id=a2"); +}); diff --git a/gui/tests/codex-auth-picker-save.test.tsx b/gui/tests/codex-auth-picker-save.test.tsx new file mode 100644 index 000000000..0fc4a7cb8 --- /dev/null +++ b/gui/tests/codex-auth-picker-save.test.tsx @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import { LanguageProvider } from "../src/i18n/provider"; +import CodexAuth from "../src/pages/CodexAuth"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let previousFetch: typeof globalThis.fetch; +let testWindow: Window; +let root: Root | null = null; + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + previousFetch = globalThis.fetch; + testWindow = new Window({ url: "http://localhost/#codex-auth" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(async () => { + if (root) { + const mounted = root; + await act(async () => { mounted.unmount(); }); + root = null; + } + Object.defineProperty(globalThis, "fetch", { configurable: true, value: previousFetch }); + await testWindow.happyDOM?.close?.(); + for (const key of globals) { + Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); + } +}); + +test("rapid account-picker clicks serialize to one settings mutation", async () => { + let settingsPuts = 0; + let finishSettingsPut: (() => void) | undefined; + globalThis.fetch = (async (input, init) => { + const raw = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const url = new URL(raw, "http://localhost"); + if (url.pathname === "/api/config") { + return jsonResponse({ + codexAccountPickerEnabled: false, + providers: { openai: { codexAccountMode: "pool" } }, + }); + } + if (url.pathname === "/api/codex-auth/accounts") return jsonResponse({ accounts: [] }); + if (url.pathname === "/api/codex-auth/active") { + return jsonResponse({ activeCodexAccountId: null, autoSwitchThreshold: 80 }); + } + if (url.pathname === "/api/settings" && init?.method === "PUT") { + settingsPuts += 1; + return new Promise(resolve => { + finishSettingsPut = () => resolve(jsonResponse({ codexAccountPickerEnabled: true })); + }); + } + return jsonResponse({}); + }) as typeof fetch; + + const host = document.createElement("div"); + document.body.append(host); + const { createRoot } = await import("react-dom/client"); + await act(async () => { + root = createRoot(host); + root.render( + + + , + ); + }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); + + const toggle = host.querySelector('button[aria-pressed="false"]') as HTMLButtonElement | null; + expect(toggle).not.toBeNull(); + act(() => { + toggle!.click(); + toggle!.click(); + }); + + expect(settingsPuts).toBe(1); + expect(finishSettingsPut).toBeFunction(); + await act(async () => { + finishSettingsPut!(); + await Promise.resolve(); + await Promise.resolve(); + }); +}); diff --git a/src/codex/account-label.ts b/src/codex/account-label.ts index 4c935354b..31fc23204 100644 --- a/src/codex/account-label.ts +++ b/src/codex/account-label.ts @@ -16,7 +16,7 @@ export function fallbackCodexAccountLogLabel(accountId: string): string { return `p${createHash("sha256").update(accountId).digest("hex").slice(0, 6)}`; } -export function codexAccountLogLabel(account: CodexAccount): string { +export function codexAccountLogLabel(account: Pick): string { return CODEX_ACCOUNT_LOG_LABEL_RE.test(account.logLabel ?? "") ? account.logLabel! : fallbackCodexAccountLogLabel(account.id); diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 9186ea85b..9f8cf7765 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -7,6 +7,7 @@ import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } f import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; import { clearMainAccountInfoCache } from "./main-account-cache"; import type { OcxConfig } from "../types"; +import { visibleCodexAccountNamespaceEntries } from "./account-namespaces"; let observedMainChatgptAccountId: string | undefined; @@ -43,10 +44,13 @@ export function resetMainCodexAccountIdentityTrackingForTests(): void { observedMainChatgptAccountId = undefined; } -export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void { +export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): boolean { + const visibleBindingRemoved = visibleCodexAccountNamespaceEntries(runtimeConfig) + .some(([, boundAccountId]) => boundAccountId === accountId); removeCodexAccountCredential(accountId); runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []).filter(account => account.id !== accountId); if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); + return visibleBindingRemoved; } diff --git a/src/codex/account-namespace-match.ts b/src/codex/account-namespace-match.ts new file mode 100644 index 000000000..434662f73 --- /dev/null +++ b/src/codex/account-namespace-match.ts @@ -0,0 +1,19 @@ +export function hasCodexAccountNamespace( + namespaces: unknown, + namespace: string, +): boolean { + return !!namespaces + && typeof namespaces === "object" + && !Array.isArray(namespaces) + && Object.hasOwn(namespaces, namespace); +} + +export function codexAccountNamespaceForModel( + namespaces: unknown, + modelId: string, +): string | undefined { + const slash = modelId.indexOf("/"); + if (slash <= 0) return undefined; + const namespace = modelId.slice(0, slash); + return hasCodexAccountNamespace(namespaces, namespace) ? namespace : undefined; +} diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts new file mode 100644 index 000000000..cc87cfbc6 --- /dev/null +++ b/src/codex/account-namespaces.ts @@ -0,0 +1,183 @@ +import type { OcxConfig } from "../types"; +import { + CODEX_ACCOUNT_LOG_LABEL_RE, + createCodexAccountLogLabel, + fallbackCodexAccountLogLabel, +} from "./account-label"; +import { MAIN_CODEX_ACCOUNT_ID } from "./main-account"; + +/** Public config shorthand for the Codex Desktop/main auth.json account. */ +export const MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET = "main"; +export const CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION = "OpenAI native model bound to a Codex account namespace."; + +const RESERVED_NAMESPACE_KEYS = new Set(["__proto__", "prototype", "constructor", "combo"]); + +export function codexAccountPickerIsEnabled( + config: Pick, +): boolean { + return config.codexAccountPickerEnabled !== false + && Object.keys(config.codexAccountNamespaces ?? {}).length > 0; +} + +export function visibleCodexAccountNamespaces( + config: Pick, +): Readonly> { + if (!codexAccountPickerIsEnabled(config)) return {}; + return Object.fromEntries( + Object.entries(config.codexAccountNamespaces!) + .filter(([, accountId]) => + isMainCodexAccountTarget(accountId) + || (config.codexAccounts ?? []).some(account => !account.isMain && account.id === accountId) + ), + ); +} + +export function codexAccountPickerHasVisibleRows( + config: Pick, +): boolean { + return Object.keys(visibleCodexAccountNamespaces(config)).length > 0; +} + +function generatedNamespace(publicLabel: string): string { + const normalized = publicLabel.trim().toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[._-]+|[._-]+$/g, ""); + return normalized && !RESERVED_NAMESPACE_KEYS.has(normalized) ? normalized : "account"; +} + +function comboAliasNamespaces(config: Pick): string[] { + return Object.values(config.combos ?? {}).flatMap((combo) => { + const alias = typeof combo?.alias === "string" ? combo.alias.trim() : ""; + const slash = alias.indexOf("/"); + return slash > 0 ? [alias.slice(0, slash)] : []; + }); +} + +function defaultPublicAccountLabel(account: { id: string; alias?: string; logLabel?: string }): string { + const alias = account.alias?.trim(); + if (alias) return alias; + if (CODEX_ACCOUNT_LOG_LABEL_RE.test(account.logLabel ?? "")) return account.logLabel!; + + // Legacy or hand-edited account rows may predate persisted random log labels. Generate an + // independent public selector and persist it in the namespace map; never expose the stable, + // id-derived fallback that exists only to keep old diagnostic logs redacted. + return createCodexAccountLogLabel([fallbackCodexAccountLogLabel(account.id)]); +} + +/** Build the initial UI-managed map from public labels without exposing stored account ids. */ +export function defaultCodexAccountNamespaces( + config: Pick, +): Record { + const namespaces: Record = {}; + const used = new Set([ + ...Object.keys(config.providers), + ...comboAliasNamespaces(config), + ...RESERVED_NAMESPACE_KEYS, + ]); + const claim = (requested: string): string => { + const base = generatedNamespace(requested); + let candidate = base; + let suffix = 2; + while (used.has(candidate)) candidate = `${base}-${suffix++}`; + used.add(candidate); + return candidate; + }; + + namespaces[claim("main")] = MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET; + for (const account of config.codexAccounts ?? []) { + if (account.isMain || isMainCodexAccountTarget(account.id)) continue; + namespaces[claim(defaultPublicAccountLabel(account))] = account.id; + } + return namespaces; +} + +/** Add a newly stored account to an existing UI-managed map without renaming old rows. */ +export function appendDefaultCodexAccountNamespace( + config: Pick, + account: { id: string; alias?: string; logLabel?: string; isMain: boolean }, +): boolean { + if (account.isMain + || isMainCodexAccountTarget(account.id) + || !config.codexAccountNamespaces + || Object.keys(config.codexAccountNamespaces).length === 0) return false; + if (Object.values(config.codexAccountNamespaces).includes(account.id)) return false; + const used = new Set([ + ...Object.keys(config.providers), + ...comboAliasNamespaces(config), + ...Object.keys(config.codexAccountNamespaces), + ...RESERVED_NAMESPACE_KEYS, + ]); + const base = generatedNamespace(defaultPublicAccountLabel(account)); + let namespace = base; + let suffix = 2; + while (used.has(namespace)) namespace = `${base}-${suffix++}`; + config.codexAccountNamespaces[namespace] = account.id; + return true; +} + +export function codexAccountNamespaceDisplayName(namespace: string): string { + return namespace + .split(/([._-]+)/) + .map(part => /^[._-]+$/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); +} + +export function accountBoundNativeDisplayName(namespace: string, native: { + slug?: unknown; + display_name?: unknown; +}): string { + const raw = typeof native.display_name === "string" + ? native.display_name + : String(native.slug ?? ""); + const model = raw + .replace(/^gpt-/i, "") + .split("-") + .filter(Boolean) + .map(part => /^[a-z]/.test(part) ? part.charAt(0).toUpperCase() + part.slice(1) : part) + .join(" "); + return `${codexAccountNamespaceDisplayName(namespace)} / ${model}`; +} + +export function accountBoundNativeCatalogSlug(entry: { + slug?: unknown; + description?: unknown; +}): string | undefined { + if (entry.description !== CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION || typeof entry.slug !== "string") { + return undefined; + } + const slash = entry.slug.indexOf("/"); + return slash > 0 ? entry.slug.slice(slash + 1) : undefined; +} + +export function isMainCodexAccountTarget(accountId: string): boolean { + return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || accountId === MAIN_CODEX_ACCOUNT_ID; +} + +export function normalizeCodexAccountNamespaceTarget(accountId: string): string { + return isMainCodexAccountTarget(accountId) + ? MAIN_CODEX_ACCOUNT_ID + : accountId; +} + +export function codexAccountNamespaceEntries( + config: Pick, +): Array<[string, string]> { + return Object.entries(config.codexAccountNamespaces ?? {}) + .map(([namespace, accountId]) => [namespace, normalizeCodexAccountNamespaceTarget(accountId)]); +} + +export function visibleCodexAccountNamespaceEntries( + config: Pick, +): Array<[string, string]> { + return Object.entries(visibleCodexAccountNamespaces(config)) + .map(([namespace, accountId]) => [namespace, normalizeCodexAccountNamespaceTarget(accountId)]); +} + +export function accountBoundNativeModelSlugs( + config: Pick, + nativeSlugs: Iterable, +): string[] { + const natives = [...nativeSlugs]; + return visibleCodexAccountNamespaceEntries(config) + .flatMap(([namespace]) => natives.map(slug => `${namespace}/${slug}`)); +} diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 6aaf9f68a..3d5eb853f 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -10,6 +10,11 @@ import { TokenRefreshError, } from "./account-store"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; +import { + appendDefaultCodexAccountNamespace, + codexAccountPickerIsEnabled, + isMainCodexAccountTarget, +} from "./account-namespaces"; import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; @@ -59,7 +64,14 @@ function jsonResponse(data: unknown, status = 200): Response { const ACCOUNT_ID_RE = /^[a-zA-Z0-9._-]{1,64}$/; const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT"; -const codexAuthLoginState = new Map(); +const codexAuthLoginState = new Map(); function configuredPoolAccount(config: OcxConfig, accountId: string): CodexAccount | null { if (!ACCOUNT_ID_RE.test(accountId)) return null; @@ -462,6 +474,24 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa return [main, ...withQuota]; } +async function refreshAccountNamespaceCatalog(config: OcxConfig, changed: boolean): Promise { + if (!changed || !codexAccountPickerIsEnabled(config)) return false; + for (let attempt = 0; attempt < 2; attempt++) { + try { + // Keep module loading inside the recoverable boundary too: the account mutation is already + // durable, so a packaging/load failure must report stale catalog state rather than a 500. + const { refreshCodexModelCatalog } = await import("./refresh"); + await refreshCodexModelCatalog(config); + return false; + } catch { + // Retry once before reporting recoverable stale-catalog state. The account mutation is + // already durable, so rolling it back would be more misleading than exposing the retry. + } + } + console.warn("[codex-auth] Account picker catalog refresh failed after retry; run `ocx sync` to retry."); + return true; +} + export async function handleCodexAuthAPI( req: Request, url: URL, @@ -484,6 +514,9 @@ export async function handleCodexAuthAPI( if (!ACCOUNT_ID_RE.test(body.id)) { return jsonResponse({ error: "Invalid account id format" }, 400); } + if (isMainCodexAccountTarget(body.id)) { + return jsonResponse({ error: "Account id is reserved for the main Codex login" }, 400); + } if (body.accessToken.length > 10_000 || body.refreshToken.length > 10_000) { return jsonResponse({ error: "Input too large" }, 400); } @@ -511,19 +544,28 @@ export async function handleCodexAuthAPI( }); markCodexAccountValidated(body.id, warmup.validatedAt); clearAccountNeedsReauth(body.id); - accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts)); + const addedAccount = withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts); + const retainedPickerBindingRestored = codexAccountPickerIsEnabled(runtimeConfig) + && Object.values(runtimeConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id); + accounts.push(addedAccount); runtimeConfig.codexAccounts = accounts; + const namespaceAdded = appendDefaultCodexAccountNamespace(runtimeConfig, addedAccount); saveRuntimeConfig(config, runtimeConfig); - return jsonResponse({ ok: true }); + const catalogRefreshPending = await refreshAccountNamespaceCatalog( + runtimeConfig, + namespaceAdded || retainedPickerBindingRestored, + ); + return jsonResponse({ ok: true, catalogRefreshPending }); } if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") { const id = url.searchParams.get("id"); if (!id) return jsonResponse({ error: "Missing id" }, 400); const runtimeConfig = getRuntimeConfig(config); - deleteCodexAccount(runtimeConfig, id); + const pickerChanged = deleteCodexAccount(runtimeConfig, id); saveRuntimeConfig(config, runtimeConfig); - return jsonResponse({ ok: true }); + const catalogRefreshPending = await refreshAccountNamespaceCatalog(runtimeConfig, pickerChanged); + return jsonResponse({ ok: true, catalogRefreshPending }); } if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") { @@ -674,13 +716,21 @@ export async function handleCodexAuthAPI( } const accountId = requestedAccountId || `chatgpt-${Date.now()}`; const runtimeConfig = getRuntimeConfig(config); + const existingPoolAccount = requestedAccountId + ? configuredPoolAccount(runtimeConfig, requestedAccountId) + : undefined; + if (requestedAccountId + && isMainCodexAccountTarget(requestedAccountId) + && !(reauth && existingPoolAccount)) { + return jsonResponse({ error: "Account id is reserved for the main Codex login" }, 400); + } const exists = (runtimeConfig.codexAccounts ?? []).some(a => a.id === accountId) || Boolean(getCodexAccountCredential(accountId)); if (exists && !reauth) { return jsonResponse({ error: `Account id already exists: ${accountId}` }, 400); } if (reauth) { if (!requestedAccountId) return jsonResponse({ error: "id required for reauth" }, 400); - if (!configuredPoolAccount(runtimeConfig, accountId)) { + if (!existingPoolAccount) { return jsonResponse({ error: "Unknown pool account for reauth" }, 404); } } @@ -810,6 +860,7 @@ export async function handleCodexAuthAPI( const latestConfig = getRuntimeConfig(config); const accounts = latestConfig.codexAccounts ?? []; const existingIdx = accounts.findIndex(a => a.id === accountId); + let catalogRefreshPending = false; if (existingIdx >= 0) { // Keep the pool id stable; refresh display metadata after a successful login/reauth. accounts[existingIdx] = withCodexAccountLogLabel({ @@ -821,11 +872,25 @@ export async function handleCodexAuthAPI( latestConfig.codexAccounts = accounts; saveRuntimeConfig(config, latestConfig); } else { - accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts)); + const addedAccount = withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts); + const retainedPickerBindingRestored = codexAccountPickerIsEnabled(latestConfig) + && Object.values(latestConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id); + accounts.push(addedAccount); latestConfig.codexAccounts = accounts; + const namespaceAdded = appendDefaultCodexAccountNamespace(latestConfig, addedAccount); saveRuntimeConfig(config, latestConfig); + catalogRefreshPending = await refreshAccountNamespaceCatalog( + latestConfig, + namespaceAdded || retainedPickerBindingRestored, + ); } - codexAuthLoginState.set(flowId, { status: "done", accountId, email, doneAt: Date.now() }); + codexAuthLoginState.set(flowId, { + status: "done", + accountId, + email, + ...(catalogRefreshPending ? { catalogRefreshPending: true } : {}), + doneAt: Date.now(), + }); completed = true; } break; diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index d0ff402c1..4a88a66bd 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -27,6 +27,8 @@ export type CodexAuthContext = generation: number; accessToken: string; chatgptAccountId: string; + /** Prevent pool affinity or active-account mutation for an exact selector. */ + fixedAccount?: boolean; /** * Set when this request was admitted through an active quota cooldown as * the account's single probe. Must be echoed into the upstream outcome so @@ -41,6 +43,8 @@ export type CodexAuthContext = accountId: string; accessToken: string; chatgptAccountId: string; + /** Prevent pool affinity or active-account mutation for an exact selector. */ + fixedAccount?: boolean; /** See `pool.probeLeaseId`. */ probeLeaseId?: string; }; @@ -120,6 +124,8 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): export interface ResolveCodexAuthContextOptions { excludeAccountId?: string; + /** Resolve exactly this account without consulting or mutating pool selection/affinity. */ + accountId?: string; } export async function resolveCodexAuthContext( @@ -128,13 +134,21 @@ export async function resolveCodexAuthContext( mode: CodexAccountMode, options: ResolveCodexAuthContextOptions = {}, ): Promise { - if (mode === "direct") { + if (options.accountId && options.excludeAccountId) { + throw new Error("Codex auth context cannot select and exclude an account simultaneously"); + } + // An explicit account binding is stronger than the provider's default mode. Account-qualified + // model selectors must resolve that exact credential even while the canonical provider is in + // Direct mode; otherwise they would silently fall back to the caller's current login. + if (mode === "direct" && !options.accountId) { if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); return { kind: "main", accountId: null }; } reconcileMainCodexAccountRuntimeState(); const threadId = headers.get("x-codex-parent-thread-id"); - const resolution = options.excludeAccountId + const resolution = options.accountId + ? { status: "selected" as const, accountId: options.accountId } + : options.excludeAccountId ? (() => { const accountId = pickLowestUsageCodexAccount(config, options.excludeAccountId); return accountId @@ -145,12 +159,15 @@ export async function resolveCodexAuthContext( if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId); const accountId = resolution.status === "selected" ? resolution.accountId : null; if (!accountId) throw new CodexPoolAuthenticationError(); + if (options.accountId && !isCodexAccountUsable(config, accountId)) { + throw new CodexPoolAuthenticationError(); + } // Lazy prime: if the selected account has no quota yet, the pool is likely // unprimed (dashboard never opened, or startup prime was blocked). Kick a // best-effort prime so the NEXT routing decision has real scores. This never // blocks the current request, and the helper's single-flight guard collapses // repeated triggers into one pass. - if (!getAccountQuota(accountId)) { + if (!options.accountId && !getAccountQuota(accountId)) { import("./auth-api") .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "pre-route")) .catch(() => {}); @@ -161,6 +178,9 @@ export async function resolveCodexAuthContext( // interval; its outcome decides whether the cooldown ends (#433). let probeLeaseId: string | undefined; if (cooldownUntil) { + // Account-qualified selectors are strict bindings, not pool recovery traffic. They fail + // closed while cooled instead of consuming the pool's one recovery probe lease. + if (options.accountId) throw new CodexAccountCooldownError(accountId, cooldownUntil); probeLeaseId = tryAcquireCodexQuotaProbeLease(accountId) ?? undefined; if (!probeLeaseId) throw new CodexAccountCooldownError(accountId, cooldownUntil); } @@ -178,6 +198,7 @@ export async function resolveCodexAuthContext( accountId, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, + ...(options.accountId ? { fixedAccount: true } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), }; } @@ -190,6 +211,7 @@ export async function resolveCodexAuthContext( generation: token.generation, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, + ...(options.accountId ? { fixedAccount: true } : {}), ...(probeLeaseId ? { probeLeaseId } : {}), }; } catch (cause) { diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 8587c286a..ce57c4d46 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -29,6 +29,7 @@ import type { NormalizedComboConfig } from "../../combos/types"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; +import { accountBoundNativeCatalogSlug } from "../account-namespaces"; import { filterSupportedNativeSlugs } from "./parsing"; @@ -131,11 +132,19 @@ export function nativeModelRows(config: Pick): Arra }); } -export function applyNativeVisibility(entries: RawEntry[], disabledNative: Set): RawEntry[] { +export function applyNativeVisibility( + entries: RawEntry[], + disabledNative: Set, + hideUnqualifiedNative = false, +): RawEntry[] { for (const entry of entries) { const slug = typeof entry.slug === "string" ? entry.slug : ""; - if (!slug || slug.includes("/") || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(slug)) continue; - entry.visibility = disabledNative.has(slug) ? "hide" : "list"; + const accountBoundSlug = accountBoundNativeCatalogSlug(entry); + const nativeSlug = accountBoundSlug ?? slug; + if (!nativeSlug || (!accountBoundSlug && slug.includes("/")) || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue; + entry.visibility = disabledNative.has(nativeSlug) || (!accountBoundSlug && hideUnqualifiedNative) + ? "hide" + : "list"; } return entries; } diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 9022dde71..c83a5c040 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -29,6 +29,7 @@ import type { NormalizedComboConfig } from "../../combos/types"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; +import { accountBoundNativeCatalogSlug } from "../account-namespaces"; import { NATIVE_OPENAI_CONTEXT_OVERRIDES, SUPPORTED_NATIVE_OPENAI_SLUGS, UPSTREAM_NATIVE_ENTRIES } from "./metadata"; @@ -278,7 +279,8 @@ export function applyMultiAgentMode(entries: RawEntry[], mode: MultiAgentMode): // Restore upstream defaults: clear any stale forced multi_agent_version and // re-apply upstream pins from the snapshot for native entries that have one. for (const entry of entries) { - const slug = typeof entry.slug === "string" ? entry.slug : ""; + const slug = accountBoundNativeCatalogSlug(entry) + ?? (typeof entry.slug === "string" ? entry.slug : ""); const upstream = UPSTREAM_NATIVE_ENTRIES.get(slug); const upstreamPin = upstream?.multi_agent_version; if (typeof upstreamPin === "string") { diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 052c057ee..ae6e0cf13 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -29,6 +29,14 @@ import type { NormalizedComboConfig } from "../../combos/types"; import { providerDestinationResolvedError } from "../../lib/destination-policy"; import { redactSecretString } from "../../lib/redact"; import upstreamModelsSnapshot from "../data/upstream-models.json"; +import { + accountBoundNativeDisplayName, + accountBoundNativeCatalogSlug, + CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, + visibleCodexAccountNamespaceEntries, + codexAccountPickerHasVisibleRows, + visibleCodexAccountNamespaces, +} from "../account-namespaces"; import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogModelSlug, ensureCatalogBackup, ensureStrictCatalogFields, findNativeTemplate, isRoutedModelCompatibilityExcluded, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; @@ -71,6 +79,13 @@ export function configuredCatalogEntry(entries: RawEntry[], configured: string): ?? entries.find(entry => typeof entry.slug === "string" && slugsEquivalent(configured, entry.slug)); } +function configuredSubagentModelMatchesEntry(configured: string, entry: RawEntry): boolean { + if (typeof entry.slug !== "string") return false; + if (slugsEquivalent(configured, entry.slug)) return true; + const nativeSlug = accountBoundNativeCatalogSlug(entry); + return !configured.includes("/") && nativeSlug !== undefined && slugsEquivalent(configured, nativeSlug); +} + export function effectiveSubagentRoster( configuredModels: readonly string[], surface: SpawnAgentSurface, @@ -99,23 +114,40 @@ export function effectiveSubagentRoster( model: entry.slug as string, efforts: catalogEntryEfforts(entry), })); - const advertised = candidates.filter(candidate => - configured.some(model => slugsEquivalent(model, candidate.model)) - ); + const advertised = ordered + .filter(({ entry }) => configured.some(model => configuredSubagentModelMatchesEntry(model, entry))) + .map(({ entry }) => ({ + model: entry.slug as string, + efforts: catalogEntryEfforts(entry), + })); const excluded = configured.flatMap((model): SubagentRosterExclusion[] => { - const entry = configuredCatalogEntry(entries, model); - if (!entry) return [{ configured: model, reason: "missing_catalog_entry" }]; - const catalogModel = entry.slug as string; - if (entry.visibility !== "list") { - return [{ configured: model, catalogModel, reason: "picker_hidden" }]; - } - if (surface === "v2" && entry.multi_agent_version !== "v2") { - return [{ configured: model, catalogModel, reason: "surface_incompatible" }]; + if (ordered.some(({ entry }) => configuredSubagentModelMatchesEntry(model, entry))) return []; + const matchingEntries = entries.filter(entry => configuredSubagentModelMatchesEntry(model, entry)); + if (matchingEntries.length === 0) return [{ configured: model, reason: "missing_catalog_entry" }]; + + const visibleCompatible = matchingEntries.find(entry => + entry.visibility === "list" + && (surface !== "v2" || entry.multi_agent_version === "v2") + ); + if (visibleCompatible) { + return [{ + configured: model, + catalogModel: visibleCompatible.slug as string, + reason: "outside_display_limit", + }]; } - if (!candidates.some(candidate => candidate.model === catalogModel)) { - return [{ configured: model, catalogModel, reason: "outside_display_limit" }]; + + const visible = matchingEntries.find(entry => entry.visibility === "list"); + if (visible) { + return [{ + configured: model, + catalogModel: visible.slug as string, + reason: "surface_incompatible", + }]; } - return []; + + const hidden = configuredCatalogEntry(entries, model) ?? matchingEntries[0]!; + return [{ configured: model, catalogModel: hidden.slug as string, reason: "picker_hidden" }]; }); return { candidates, advertised, excluded }; } @@ -233,6 +265,7 @@ export function buildCatalogEntries( wsEnabled = false, multiAgentMode: MultiAgentMode = "default", exactComboSlugs: ReadonlySet = new Set(), + accountNamespaces: Readonly> = {}, ): RawEntry[] { // Codex's models-manager sorts by `priority` ASC and advertises the first 5 picker-visible // models to spawn_agent (sort_by_key(priority) + MAX_MODEL_OVERRIDES_IN_SPAWN_AGENT=5). Catalog @@ -240,6 +273,7 @@ export function buildCatalogEntries( // it sorts to the front. This works for native gpt slugs AND routed slugs alike. const rank = new Map((featured ?? []).map((slug, i) => [slug, i] as const)); const out: RawEntry[] = []; + const nativeEntries: RawEntry[] = []; const collisionSkipped = resolveSlugAliasCollisions(goModels); const comboPublicSlugs = new Set(goModels .filter(model => model.provider === COMBO_NAMESPACE) @@ -247,7 +281,28 @@ export function buildCatalogEntries( for (const slug of gptSlugs) { const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9); if (rank.has(slug)) e.priority = rank.get(slug)!; + if (Object.keys(accountNamespaces).length > 0) { + e.visibility = "hide"; + } out.push(e); + nativeEntries.push(e); + } + const namespaceNames = Object.keys(accountNamespaces); + for (const [namespaceIndex, namespace] of namespaceNames.entries()) { + for (const native of nativeEntries) { + const slug = `${namespace}/${String(native.slug)}`; + const e = JSON.parse(JSON.stringify(native)) as RawEntry; + e.slug = slug; + e.display_name = accountBoundNativeDisplayName(namespace, native); + e.description = CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION; + const exactRank = rank.get(slug); + const inheritedRank = rank.get(String(native.slug)); + const nativePriority = typeof native.priority === "number" ? native.priority : 9; + e.priority = exactRank + ?? (inheritedRank ?? nativePriority) * namespaceNames.length + namespaceIndex; + e.visibility = "list"; + out.push(e); + } } for (const m of goModels) { if (collisionSkipped.has(m)) continue; @@ -269,6 +324,11 @@ export function buildCatalogEntries( // Featured picks may be stored raw (legacy) or encoded — honor both. const rankHit = rank.get(slug) ?? rank.get(`${m.provider}/${m.id}`); if (rankHit !== undefined) e.priority = rankHit; + else if (namespaceNames.length > 0) { + // Keep the account-qualified native block contiguous in Codex's flat picker. Featured + // routed subagent choices retain their explicit rank at the front of the catalog. + e.priority = 1_000 + (typeof e.priority === "number" ? e.priority : 5); + } out.push(e); } // Central capability override (phase 120.4): the advertised flag must match the implemented WS @@ -327,6 +387,8 @@ export function mergeCatalogEntriesForSync( multiAgentMode: MultiAgentMode = "default", exactComboSlugs: ReadonlySet = new Set(), hasPhysicalComboProvider = false, + accountNamespacesEnabled = false, + accountBoundEntries: readonly RawEntry[] = [], ): RawEntry[] { const rank = new Map(featured.map((slug, i) => [slug, i] as const)); const native = catalogModels @@ -385,13 +447,19 @@ export function mergeCatalogEntriesForSync( routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []), ); let finalRoutedEntries = routedEntries; + const existingRoutedEntries = catalogModels.filter(m => + typeof m.slug === "string" + && (m.slug as string).includes("/") + && accountBoundNativeCatalogSlug(m) === undefined + ); const preservingExistingRouted = routedEntries.length === 0 - && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/")); + && existingRoutedEntries.length > 0; if (preservingExistingRouted) { - finalRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" && (m.slug as string).includes("/")); + finalRoutedEntries = existingRoutedEntries; } else { const preservedForeignRouted = catalogModels.filter(m => { if (typeof m.slug !== "string" || !m.slug.includes("/")) return false; + if (accountBoundNativeCatalogSlug(m) !== undefined) return false; const provider = m.slug.slice(0, m.slug.indexOf("/")); return !gatheredProviderNames.has(provider) && !freshSlugs.has(m.slug); }); @@ -414,17 +482,27 @@ export function mergeCatalogEntriesForSync( finalRoutedEntries = finalRoutedEntries.filter(entry => typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug) ); + // A namespace can legitimately reuse the prefix of a provider that was removed after a prior + // sync. In that case an unmarked stale provider row may have the same slug as the new exact- + // account row. The current account binding is authoritative; never emit both identities. + const accountBoundSlugs = new Set(accountBoundEntries.flatMap(entry => + typeof entry.slug === "string" ? [entry.slug] : [] + )); + finalRoutedEntries = finalRoutedEntries.filter(entry => + typeof entry.slug !== "string" || !accountBoundSlugs.has(entry.slug) + ); if (preservingExistingRouted) { console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`); } - const mergedEntries = [...native, ...finalRoutedEntries].map(m => { + const managedEntries = [...finalRoutedEntries, ...accountBoundEntries]; + const mergedEntries = [...native, ...managedEntries].map(m => { const normalized = normalizeServiceTiers(m); applyNativeOpenAiContextOverride(normalized); const exactCombo = typeof m.slug === "string" && exactComboSlugs.has(m.slug); const e = ensureStrictCatalogFields(normalized, { preserveExactInputModalities: exactCombo, - isRouted: finalRoutedEntries.includes(m), + isRouted: managedEntries.includes(m), }); // Mock-max universality (260709): preserved routed entries from disk may predate // the max rung — ensure it here so subagent max spawns validate on every @@ -449,7 +527,11 @@ export function mergeCatalogEntriesForSync( }); // Native enable/disable (single choke point: bare slugs in `disabledModels`). Runs as the // LAST pass so the upstream-upgrade branch above can never clobber a hide flag back to list. - return applyMultiAgentMode(applyNativeVisibility(mergedEntries, disabledNative), multiAgentMode); + return applyMultiAgentMode(applyNativeVisibility( + mergedEntries, + disabledNative, + accountNamespacesEnabled, + ), multiAgentMode); } export async function syncCatalogModels(config: OcxConfig): Promise<{ added: number; path: string }> { @@ -474,7 +556,27 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; const exactComboSlugs = exactComboCatalogSlugs(config); const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE); - const goEntries = buildCatalogEntries(template ? JSON.parse(JSON.stringify(template)) : null, [], orderedGoModels, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs); + const routedEntries = buildCatalogEntries( + template ? JSON.parse(JSON.stringify(template)) : null, + [], + orderedGoModels, + featured, + websocketsEnabled(config), + multiAgentMode, + exactComboSlugs, + visibleCodexAccountNamespaces(config), + ); + const accountBoundEntries = buildCatalogEntries( + template ? JSON.parse(JSON.stringify(template)) : null, + nativeOpenAiSlugs(), + [], + featured, + websocketsEnabled(config), + multiAgentMode, + exactComboSlugs, + visibleCodexAccountNamespaces(config), + ).filter(entry => accountBoundNativeCatalogSlug(entry) !== undefined); + const addedCount = routedEntries.length + accountBoundEntries.length; // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append // routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids // like `gpt-5.5`; those must not delete the native OpenAI/Codex base row. @@ -485,15 +587,33 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num .filter(([, prov]) => prov.disabled !== true) .map(([name]) => name), ); + for (const [namespace] of visibleCodexAccountNamespaceEntries(config)) gatheredProviderNames.add(namespace); // Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to // native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a // native template can never leak supports_websockets while the flag is off. const wsEnabled = websocketsEnabled(config); - catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider); + // Account-bound rows are generated locally, so they must not make an empty routed discovery look + // successful. Merge them separately so outage preservation applies only to provider-owned rows. + catalog.models = mergeCatalogEntriesForSync( + catalog.models ?? [], + routedEntries, + baseline, + featured, + wsEnabled, + goIds, + template, + disabledNativeSlugs(config), + gatheredProviderNames, + multiAgentMode, + exactComboSlugs, + hasPhysicalComboProvider, + codexAccountPickerHasVisibleRows(config), + accountBoundEntries, + ); clampCatalogModelsToCodexSupport(catalog.models); atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n"); - return { added: goEntries.length, path: catalogPath }; + return { added: addedCount, path: catalogPath }; } export function restoreCodexCatalog(): { removed: number; kept: number; path: string } { diff --git a/src/codex/routing.ts b/src/codex/routing.ts index e67b6a44a..25ab1147e 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -100,6 +100,8 @@ export type CodexUpstreamOutcomeMeta = { now?: number; /** When set, clears affinity for this thread immediately on transient failure. */ threadId?: string | null; + /** Suppress active-account and affinity changes for an account-qualified request. */ + fixedAccount?: boolean; /** * Probe lease held by this request, when it was admitted through an active * quota cooldown. Only the outcome carrying the current lease may clear the @@ -635,7 +637,7 @@ export function recordCodexUpstreamOutcome( lastFailureAt: now, }); markAccountNeedsReauth(accountId); - clearThreadAccountMapForAccount(accountId); + if (!meta.fixedAccount) clearThreadAccountMapForAccount(accountId); return; } @@ -663,8 +665,8 @@ export function recordCodexUpstreamOutcome( ...(prior?.lastProbeAt !== undefined ? { lastProbeAt: prior.lastProbeAt } : {}), }), }); - clearThreadAccountMapForAccount(accountId); - if (config.activeCodexAccountId === accountId) { + if (!meta.fixedAccount) clearThreadAccountMapForAccount(accountId); + if (!meta.fixedAccount && config.activeCodexAccountId === accountId) { const fallback = pickLowestUsageCodexAccount(config, accountId, now); if (fallback) setActiveCodexAccount(config, fallback); } @@ -704,16 +706,16 @@ export function recordCodexUpstreamOutcome( // thread is still pinned to the FAILING account — a late failure from account A // must not delete a newer healthy binding to account B (race: T→A, A fails, // T→B, late A failure must not delete B's mapping). - if (failoverEnabled && meta.threadId) { + if (!meta.fixedAccount && failoverEnabled && meta.threadId) { const bound = threadAccountMap.get(meta.threadId); if (bound?.accountId === accountId) threadAccountMap.delete(meta.threadId); } // Once the account is past the failover streak, clear every thread still pinned // to it — matching 429 affinity behavior so "continue" cannot stay on a bad peer. - if (shouldFailover(config, accountId, now)) { + if (!meta.fixedAccount && shouldFailover(config, accountId, now)) { clearThreadAccountMapForAccount(accountId); } - if (config.activeCodexAccountId === accountId) applyFailureFailover(config, accountId, now); + if (!meta.fixedAccount && config.activeCodexAccountId === accountId) applyFailureFailover(config, accountId, now); } export function formatCodexProviderForLog(providerName: string, accountId: string | null, config: OcxConfig): string { diff --git a/src/config.ts b/src/config.ts index a2b3a4c4b..52bdc0998 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,6 +3,7 @@ import { copyFileSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync import { homedir } from "node:os"; import { join, resolve } from "node:path"; import * as z from "zod/v4"; +import { codexAccountNamespaceForModel } from "./codex/account-namespace-match"; import { comboConfigIssues } from "./combos/types"; import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl"; import { providerDestinationConfigError } from "./lib/destination-policy"; @@ -490,6 +491,48 @@ const configSchema = z.object({ // path below and wipe providers/pool accounts. Warning emitted in loadConfig. streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined), }).passthrough().superRefine((config, ctx) => { + const accountPickerEnabled = (config as { codexAccountPickerEnabled?: unknown }).codexAccountPickerEnabled; + if (accountPickerEnabled !== undefined && typeof accountPickerEnabled !== "boolean") { + ctx.addIssue({ + code: "custom", + path: ["codexAccountPickerEnabled"], + message: "codexAccountPickerEnabled must be a boolean", + }); + } + const accountNamespaces = (config as { codexAccountNamespaces?: unknown }).codexAccountNamespaces; + if (accountNamespaces !== undefined) { + if (!accountNamespaces || typeof accountNamespaces !== "object" || Array.isArray(accountNamespaces)) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces"], + message: "codexAccountNamespaces must be an object mapping model namespaces to Codex account ids", + }); + } else { + for (const [namespace, accountId] of Object.entries(accountNamespaces as Record)) { + if (!isValidProviderName(namespace)) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: "account namespaces must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys", + }); + } + if (namespace === "combo" || hasOwnProvider(config.providers, namespace)) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: "account namespaces must not collide with configured provider or combo namespaces", + }); + } + if (typeof accountId !== "string" || accountId.trim() !== accountId || accountId.length === 0) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespaces", namespace], + message: "account namespace targets must be nonblank Codex account ids", + }); + } + } + } + } for (const name of Object.keys(config.providers)) { if (!isValidProviderName(name)) { ctx.addIssue({ @@ -640,6 +683,18 @@ const configSchema = z.object({ ctx.addIssue({ code: "custom", path: ["combos"], message: "combos must be an object" }); } else { for (const [id, raw] of Object.entries(combos as Record)) { + const alias = raw && typeof raw === "object" && !Array.isArray(raw) + ? (raw as { alias?: unknown }).alias + : undefined; + if (typeof alias === "string") { + if (codexAccountNamespaceForModel(accountNamespaces, alias)) { + ctx.addIssue({ + code: "custom", + path: ["combos", id, "alias"], + message: "combo aliases must not use a configured Codex account namespace", + }); + } + } // Pass the full map so cross-combo rules (alias uniqueness) apply at load time // too, not just via the management API; each combo is excluded from its own check. for (const issue of comboConfigIssues(id, raw, config.providers, { diff --git a/src/router.ts b/src/router.ts index 618f43b34..632e53e70 100644 --- a/src/router.ts +++ b/src/router.ts @@ -3,15 +3,26 @@ import { preservesPhysicalComboProvider, tryPickComboModel, type ComboPick } fro import { hasOwnProvider, resolveEnvValue } from "./config"; import { assertProviderDestinationAllowed } from "./lib/destination-policy"; import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry"; -import { LEGACY_CHATGPT_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers"; +import { + isCanonicalOpenAiForwardProvider, + LEGACY_CHATGPT_PROVIDER_ID, + LEGACY_OPENAI_MULTI_PROVIDER_ID, + OPENAI_API_PROVIDER_ID, + OPENAI_CODEX_PROVIDER_ID, +} from "./providers/openai-tiers"; import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec"; import { getStaleCached } from "./codex/model-cache"; +import { codexAccountNamespaceEntries } from "./codex/account-namespaces"; export interface RouteResult { providerName: string; provider: OcxProviderConfig; modelId: string; codexAccountMode?: CodexAccountMode; + /** Exact account selected by an account-qualified native model; never participates in pool selection. */ + codexAccountId?: string; + /** User-owned namespace used by the public model selector (safe for display/logging). */ + codexAccountNamespace?: string; combo?: ComboPick; } @@ -235,6 +246,30 @@ function routeResult(providerName: string, provider: OcxProviderConfig, modelId: } function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: boolean): RouteResult { + const slash = modelId.indexOf("/"); + if (slash > 0) { + const namespace = modelId.slice(0, slash); + const binding = codexAccountNamespaceEntries(config).find(([candidate]) => candidate === namespace); + if (binding) { + const nativeModelId = modelId.slice(slash + 1); + if (!isBareOpenAiFamilyModel(nativeModelId)) { + throw new Error(`Codex account namespace ${namespace} only supports native OpenAI model ids`); + } + const provider = config.providers[OPENAI_CODEX_PROVIDER_ID]; + if (!provider || provider.disabled === true || !isCanonicalOpenAiForwardProvider(provider)) { + throw new NoEnabledOpenAiProviderError(nativeModelId); + } + const routed = routeResult(OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId); + return { + ...routed, + // Exact account injection uses the pool credential machinery even when the canonical + // provider is globally direct. The fixed id below bypasses pool selection entirely. + codexAccountMode: "pool", + codexAccountId: binding[1], + codexAccountNamespace: namespace, + }; + } + } if (!bypassCombos && !preservesPhysicalComboProvider(config)) { const combo = tryPickComboModel(config, modelId); if (combo) { @@ -250,7 +285,6 @@ function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: bo // Only triggers when the prefix matches a CONFIGURED provider, so genuine // slash-containing model ids (e.g. "anthropic/claude-...") fall through when // no such provider exists. - const slash = modelId.indexOf("/"); if (slash > 0) { const provName = modelId.slice(0, slash); if (provName === LEGACY_CHATGPT_PROVIDER_ID || provName === LEGACY_OPENAI_MULTI_PROVIDER_ID) { diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index af36826f9..b82faeca3 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -14,6 +14,7 @@ import { getProviderRegistryEntry, providerCodexAccountMode } from "../providers import { providerConfigSeed } from "../providers/derive"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { openRouterRoutingConfigError } from "../providers/openrouter-routing"; +import { codexAccountPickerIsEnabled } from "../codex/account-namespaces"; let _corsOrigin = "http://localhost:10100"; export function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; } @@ -335,6 +336,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { hostname: config.hostname ?? "127.0.0.1", defaultProvider: config.defaultProvider, codexAutoStart: codexAutoStartEnabled(config), + codexAccountPickerEnabled: codexAccountPickerIsEnabled(config), websockets: config.websockets, providers, }; diff --git a/src/server/index.ts b/src/server/index.ts index 6c301827f..6f7b343e7 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -18,6 +18,10 @@ import { } from "../config"; import { reconcileOAuthProviders } from "../oauth"; import { invalidateCodexModelsCache } from "../codex/catalog"; +import { + codexAccountPickerHasVisibleRows, + visibleCodexAccountNamespaces, +} from "../codex/account-namespaces"; import { startMemoryWatchdog } from "./memory-watchdog"; import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; @@ -347,6 +351,7 @@ export function startServer(port?: number) { } const goModels = await fetchAllModels(config); const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs } = await import("../codex/catalog"); + const { accountBoundNativeModelSlugs } = await import("../codex/account-namespaces"); const nativeSlugs = nativeOpenAiSlugs(); const goEnabled = filterCatalogVisibleModels(goModels, config); const goOrdered = orderForSubagents(goEnabled, config.subagentModels); @@ -389,13 +394,20 @@ export function startServer(port?: number) { // Disabled natives stay in the catalog shape with visibility "hide" (mirrors the // on-disk sync; codex-rs keeps them out of the picker itself). const maMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default"; - const entries = buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2", exactComboCatalogSlugs(config)); - return jsonResponse({ models: applyNativeVisibility(entries, disabledNativeSlugs(config)) }, 200, req, config); + const entries = buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2", exactComboCatalogSlugs(config), visibleCodexAccountNamespaces(config)); + return jsonResponse({ + models: applyNativeVisibility( + entries, + disabledNativeSlugs(config), + codexAccountPickerHasVisibleRows(config), + ), + }, 200, req, config); } // OpenAI list shape: native gpt bare + routed models namespaced "/" // (pure availability list — disabled natives are omitted entirely). const data = [ ...visibleNativeSlugs(config).map(id => ({ id, object: "model", created: 0, owned_by: "openai" })), + ...accountBoundNativeModelSlugs(config, visibleNativeSlugs(config)).map(id => ({ id, object: "model", created: 0, owned_by: "openai" })), ...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({ id: m.alias ?? `${m.provider}/${m.id}`, object: "model", created: 0, owned_by: m.owned_by ?? m.provider })), ]; return jsonResponse({ object: "list", data }, 200, req, config); diff --git a/src/server/management/combo-routes.ts b/src/server/management/combo-routes.ts index 564bb22fa..2744b94f9 100644 --- a/src/server/management/combo-routes.ts +++ b/src/server/management/combo-routes.ts @@ -30,6 +30,7 @@ import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; +import { codexAccountNamespaceForModel } from "../../codex/account-namespace-match"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; @@ -124,6 +125,9 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise - (subagentModels ?? []).some(model => slugsEquivalent(model, candidate.model)) - ); + // `effective.candidates` is the one global spawn_agent priority window. Resolve each + // configured role separately only to retain the catalog's account-aware association: + // bare native ids may project onto marked account-bound clones, while an arbitrary + // provider slug such as provider/gpt-* must not. Intersect those associations back + // into the original window so role-specific resolution can never widen it. + const candidateModels = new Set(effective.candidates.map(candidate => candidate.model)); + const withinCandidateWindow = (candidate: EffectiveSubagentModel): boolean => + candidateModels.has(candidate.model); + const configuredSubagents = subagentModels ?? []; + const hasConfiguredSubagents = configuredSubagents.length > 0; + let subagentEffective: EffectiveSubagentRoster | undefined; + let preferredEffective: EffectiveSubagentRoster | undefined; + if (hasConfiguredSubagents) { + subagentEffective = injectionModel + ? await resolveRoster(configuredSubagents, "v2") + : effective; + } + if (injectionModel) { + preferredEffective = hasConfiguredSubagents + ? await resolveRoster([injectionModel], "v2") + : effective; + } + const rosterModels = (subagentEffective?.advertised ?? []).filter(withinCandidateWindow); const roster = subagentRosterText(rosterModels); - const preferred = injectionModel - ? effective.candidates.find(candidate => slugsEquivalent(injectionModel, candidate.model)) - : undefined; + const preferred = preferredEffective?.advertised.find(withinCandidateWindow); if (isInjectionDebugEnabled() && effective.excluded.length > 0) { injectionDebugLog(`[opencodex] multi-agent guidance excluded: ${effective.excluded @@ -314,4 +331,3 @@ export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string): } } } - diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 300ebd208..be69b334d 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -184,7 +184,9 @@ export async function handleResponsesCompact( const selectedModelId = route.modelId; logCtx.requestedModel = raw.model; logCtx.model = selectedModelId; - logCtx.provider = route.providerName; + logCtx.provider = route.codexAccountNamespace + ? `${route.providerName}-${route.codexAccountNamespace}` + : route.providerName; logCtx.providerAdapter = route.provider.adapter; const virtual = resolveOpenAiCompactModel(route.providerName, selectedModelId); if (virtual) { @@ -216,7 +218,9 @@ export async function handleResponsesCompact( const headers = new Headers({ "content-type": "application/json" }); try { if (route.codexAccountMode) { - authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode); + authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { + accountId: route.codexAccountId, + }); const selected = headersForCodexAuthContext(req.headers, authCtx); compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode); for (const name of FORWARD_HEADERS) { @@ -256,11 +260,12 @@ export async function handleResponsesCompact( const connectMs = config.connectTimeoutMs ?? 200_000; const recordCompactPoolOutcome = (outcome: CodexUpstreamOutcome, meta: { retryAfter?: string | null } = {}) => { if (!usesCodexForwardPoolAuth(authCtx, route.provider)) return; - recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { - ...meta, - threadId: compactThreadId, - probeLeaseId: codexProbeLeaseId(authCtx), - }); + recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { + ...meta, + threadId: compactThreadId, + fixedAccount: authCtx.fixedAccount, + probeLeaseId: codexProbeLeaseId(authCtx), + }); }; let upstream: Response; try { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index e7d81f110..a8c3e63f2 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -123,9 +123,10 @@ export function sidecarOutcomeRecorder( ): ((outcome: CodexUpstreamOutcome) => void) | undefined { return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { - threadId, - probeLeaseId: authCtx.probeLeaseId, - }) + threadId, + fixedAccount: authCtx.fixedAccount, + probeLeaseId: authCtx.probeLeaseId, + }) : undefined; } @@ -212,10 +213,11 @@ export function codexForwardTerminalOutcomeRecorder( // Normal limit/content-filter/stall terminal — the account served the // request. Don't penalize account health; record success to clear any // prior soft-avoid so a healthy account isn't stuck avoided. - recordCodexUpstreamOutcome(config, authCtx.accountId, 200, { - threadId, - probeLeaseId: codexProbeLeaseId(authCtx), - }); + recordCodexUpstreamOutcome(config, authCtx.accountId, 200, { + threadId, + fixedAccount: authCtx.fixedAccount, + probeLeaseId: codexProbeLeaseId(authCtx), + }); return; } // status === "completed" or "failed": use the semantic HTTP status derived @@ -230,10 +232,11 @@ export function codexForwardTerminalOutcomeRecorder( const outcome = status === "completed" ? 200 : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502); - recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { - threadId, - probeLeaseId: codexProbeLeaseId(authCtx), - }); + recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { + threadId, + fixedAccount: authCtx.fixedAccount, + probeLeaseId: codexProbeLeaseId(authCtx), + }); }; } @@ -814,8 +817,12 @@ export async function handleResponses( // some third-party providers expose bare `defaultModel` selectors, so route.modelId // alone can make a routed model masquerade as an off-snapshot native. Only the // canonical built-in ChatGPT forward provider should receive the native clamp. + // Account namespaces are routing metadata for that canonical provider, so use + // their stripped native id rather than treating the namespace as a provider slug. { - const requestedModelId = logCtx.requestedModel ?? route.modelId; + const requestedModelId = route.codexAccountNamespace + ? route.modelId + : (logCtx.requestedModel ?? route.modelId); const { nativeEffortClamp, shouldApplyNativeEffortClamp } = await import("../../codex/catalog"); const clamped = shouldApplyNativeEffortClamp(route.providerName, route.provider, requestedModelId) ? nativeEffortClamp(route.modelId, parsed.options.reasoning) @@ -837,7 +844,9 @@ export async function handleResponses( try { if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config); if (route.codexAccountMode) { - authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode); + authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { + accountId: route.codexAccountId, + }); options.onCodexAuthContextResolved?.(authCtx); } else { options.onCodexAuthContextResolved?.(undefined); @@ -851,7 +860,9 @@ export async function handleResponses( return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session"); } if (err instanceof CodexAuthContextError) { - const safeAccountLabel = formatCodexProviderForLog(route.providerName, err.accountId, config); + const safeAccountLabel = route.codexAccountNamespace + ? `${route.providerName}-${route.codexAccountNamespace}` + : formatCodexProviderForLog(route.providerName, err.accountId, config); console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`); return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"); } @@ -872,7 +883,9 @@ export async function handleResponses( return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"); } route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode); - logCtx.provider = formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config); + logCtx.provider = route.codexAccountNamespace + ? `${route.providerName}-${route.codexAccountNamespace}` + : formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config); // Prefer Codex pool account as the Cursor thread namespace when present. Cursor routes without // codexAccountMode still get a credential-derived scope inside the Cursor adapter. const identityScope = codexLogAccountId(authCtx); @@ -1046,10 +1059,11 @@ export async function handleResponses( if (options.abortSignal?.aborted) return clientCancelledResponse(); const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error"; if (usesCodexForwardPoolAuth(authCtx, route.provider)) { - recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { - threadId: req.headers.get("x-codex-parent-thread-id"), - probeLeaseId: codexProbeLeaseId(authCtx), - }); + recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { + threadId: req.headers.get("x-codex-parent-thread-id"), + fixedAccount: authCtx.fixedAccount, + probeLeaseId: codexProbeLeaseId(authCtx), + }); } const msg = outcome === "timeout" ? `Provider connect timeout after ${connectMs}ms` @@ -1077,6 +1091,7 @@ export async function handleResponses( if ( usesCodexForwardPoolAuth(authCtx, route.provider) + && route.codexAccountId === undefined && await shouldRetryCodexPoolAccountModel400( upstreamResponse, route.modelId, @@ -1187,9 +1202,10 @@ export async function handleResponses( upstreamResponse.headers.get("x-codex-primary-reset-at"), upstreamResponse.headers.get("x-codex-secondary-reset-at"), upstreamResponse.headers.get("x-codex-tertiary-reset-at"), - ].filter(Boolean), - threadId: req.headers.get("x-codex-parent-thread-id"), - probeLeaseId: codexProbeLeaseId(authCtx), + ].filter(Boolean), + threadId: req.headers.get("x-codex-parent-thread-id"), + fixedAccount: authCtx.fixedAccount, + probeLeaseId: codexProbeLeaseId(authCtx), }); } } diff --git a/src/types.ts b/src/types.ts index d4422034b..b705c6d11 100644 --- a/src/types.ts +++ b/src/types.ts @@ -583,6 +583,17 @@ export interface OcxConfig { search?: OcxSearchConfig; /** Codex multi-account pool. */ codexAccounts?: CodexAccount[]; + /** + * Explicit model namespaces bound to one Codex account. Values are stored account ids; + * `"main"` is shorthand for the Codex Desktop/main auth.json account. A selector such as + * `side/gpt-5.6-sol` fails closed when that account is unavailable and never pool-fails over. + */ + codexAccountNamespaces?: Record; + /** + * UI-managed picker visibility for account-qualified native models. When omitted, a non-empty + * codexAccountNamespaces map is visible for backward-compatible hand-written configurations. + */ + codexAccountPickerEnabled?: boolean; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ @@ -991,7 +1002,7 @@ export function pinnedWireAdapter(providerName: string, modelId: string): string export interface CodexAccount { id: string; email: string; - /** User-owned display label; never participates in routing or identity checks. */ + /** User-owned display label; may seed a new public picker selector but never changes identity. */ alias?: string; plan?: string; chatgptAccountId?: string; diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index c38d3b941..87b5cd57a 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,8 +18,24 @@ engine. Direct short-circuits that engine before pool state is read or mutated a current caller/main-login bearer. Neither mode may fall through to `openai-apikey`, and the API provider may not fall through to Codex-login credentials. +An optional `codexAccountNamespaces` map adds account-qualified copies of every native model. The +map key is the public picker prefix and the value is an added Codex account id; `main` is shorthand +for the read-only Codex Desktop/main login. These routes use the canonical `openai` provider but +bypass Pool/Direct selection, quota balancing, failure failover, thread affinity, and unsupported- +model account retry. Cooldown, missing credentials, and reauthentication fail closed on the bound +account and do not mutate the active pool selection. + +A non-empty namespace map is the backward-compatible picker opt-in unless +`codexAccountPickerEnabled` is `false`. The dashboard uses that override to hide friendly +account-specific rows without deleting their bindings. When visible, those rows replace plain native +rows and remain grouped ahead of ordinary routed providers. Plain ids remain routable and keep their +Pool/Direct semantics for saved threads and configuration; configured account-qualified ids also +remain exact and fail closed while hidden. + ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option +main/gpt-5.6-sol # exact main/Desktop Codex account +side/gpt-5.6-sol # exact added Codex account openai-apikey/gpt-5.6-sol # OpenAI API key openai-apikey/gpt-5.6-sol-pro # API Pro virtual model ``` @@ -51,6 +67,16 @@ v2 backup blocks migration before save. - `openai` exposes one group of bare native Codex ids in Pool and Direct. Changing mode does not change catalog, selected, requested, or wire model identity. +- Each configured account namespace clones every currently supported native catalog row. When + opencodex adds a native model to its supported catalog, the next sync creates its qualified copies + without another per-namespace model list. User-owned local selector prefixes appear in the + catalog; upstream ChatGPT account ids and credentials never do. +- Existing plain model ids stay valid, while plain subagent selections project onto visible + account-specific rows. +- Binding follows each request's selected model. Qualified subagent, injection, and shadow-helper + model settings stay exact; explicit bare child/helper models retain Pool/Direct behavior. +- Standalone client-side images and alpha-search relays do not reliably carry the selected chat + model and therefore retain the canonical provider's global Pool/Direct behavior. - `openai-apikey` exposes namespaced API rows. Its trusted catalog contains `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, and the three corresponding Pro variants. No generic `gpt-5.6-pro` alias exists. - API GPT-5.6 rows use 1,050,000 context tokens and 922,000 max input tokens. Codex-login rows keep @@ -61,7 +87,9 @@ v2 backup blocks migration before save. ## Sidecars, management, and UI -HTTP/SSE, Responses WebSocket, compact, images, search, and vision resolve the same account mode. +HTTP/SSE, Responses WebSocket, and compact preserve exact account-qualified routing. Standalone +image and alpha-search relays use the canonical provider's global Pool/Direct mode because they do +not carry the selected chat model; vision follows its own transport-specific account resolution. There is one mode-aware `openai` forward sidecar candidate; `openai-apikey` is not a ChatGPT-forward sidecar candidate and cannot hide a failed Codex credential with separately billed API usage. diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index 159887314..1b5d805a8 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -155,3 +155,77 @@ test("OpenAI list shape and Codex catalog shape stay unchanged", async () => { server.stop(true); } }); + +test("account-qualified native models appear in both OpenAI and Codex discovery", async () => { + saveConfig({ + port: 0, + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + codexAccounts: [{ + id: "opaque-side-account-id", + email: "side@example.test", + isMain: false, + }], + codexAccountNamespaces: { main: "main", side: "opaque-side-account-id" }, + }); + const server = startServer(0); + try { + const plain = await fetch(new URL("/v1/models", server.url)).then(response => response.json()) as { + data: Array<{ id: string }>; + }; + expect(plain.data.some(model => model.id.startsWith("main/gpt-"))).toBe(true); + expect(plain.data.some(model => model.id.startsWith("side/gpt-"))).toBe(true); + + const codex = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)).then(response => response.json()) as { + models: Array<{ slug?: string; description?: string }>; + }; + const bound = codex.models.filter(model => model.description === "OpenAI native model bound to a Codex account namespace."); + expect(bound.some(model => model.slug?.startsWith("main/gpt-"))).toBe(true); + expect(bound.some(model => model.slug?.startsWith("side/gpt-"))).toBe(true); + expect(JSON.stringify(codex)).not.toContain("opaque-side-account-id"); + } finally { + server.stop(true); + } +}); + +test("hidden account picker rows stay out of both OpenAI and Codex discovery", async () => { + saveConfig({ + port: 0, + defaultProvider: "openai", + openaiProviderTierVersion: 2, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + codexAccountNamespaces: { main: "main", side: "opaque-side-account-id" }, + codexAccountPickerEnabled: false, + }); + const server = startServer(0); + try { + const plain = await fetch(new URL("/v1/models", server.url)).then(response => response.json()) as { + data: Array<{ id: string }>; + }; + expect(plain.data.some(model => model.id.startsWith("main/gpt-"))).toBe(false); + expect(plain.data.some(model => model.id.startsWith("side/gpt-"))).toBe(false); + expect(plain.data.some(model => model.id.startsWith("gpt-"))).toBe(true); + + const codex = await fetch(new URL("/v1/models?client_version=1.0.0", server.url)).then(response => response.json()) as { + models: Array<{ slug?: string; description?: string }>; + }; + const bound = codex.models.filter(model => model.description === "OpenAI native model bound to a Codex account namespace."); + expect(bound).toEqual([]); + expect(codex.models.some(model => model.slug?.startsWith("gpt-"))).toBe(true); + } finally { + server.stop(true); + } +}); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 5eda106fb..f8aca3079 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -23,6 +23,11 @@ import { import type { OcxConfig } from "../src/types"; import type { WsData } from "../src/server/ws-bridge"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; +import { visibleCodexAccountNamespaces } from "../src/codex/account-namespaces"; +import { deleteCodexAccount } from "../src/codex/account-lifecycle"; +import { routeModel } from "../src/router"; +import { CodexPoolAuthenticationError, resolveCodexAuthContext } from "../src/codex/auth-context"; +import * as codexRefresh from "../src/codex/refresh"; const TEST_DIR = join(import.meta.dir, ".tmp-codex-auth-api-test"); const TEST_CODEX_HOME = join(TEST_DIR, "codex"); @@ -678,6 +683,22 @@ describe("codex-auth API", () => { expect(body.error).toContain("Invalid account id"); }); + test("POST /api/codex-auth/accounts reserves main account ids", async () => { + enableManualImport(); + for (const id of ["main", "__main__"]) { + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id })), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ + error: "Account id is reserved for the main Codex login", + }); + } + }); + test("POST /api/codex-auth/accounts rejects invalid JSON when manual import is explicitly enabled", async () => { enableManualImport(); const req = new Request("http://localhost/api/codex-auth/accounts", { @@ -716,6 +737,102 @@ describe("codex-auth API", () => { expect(warmup.calls()).toBe(1); }); + test("enabled picker exposes a privacy-safe selector instead of the stored account id", async () => { + enableManualImport(); + mockCodexWarmupSuccess(); + const config = makeConfig({ + codexAccountNamespaces: { main: "main" }, + codexAccountPickerEnabled: true, + }); + const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog").mockResolvedValue({ + added: 1, + path: join(TEST_CODEX_HOME, "opencodex-catalog.json"), + catalogExists: false, + cacheSynced: false, + }); + try { + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: "private-internal-id" })), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const body = await resp!.json() as { catalogRefreshPending?: boolean }; + const publicBinding = Object.entries(config.codexAccountNamespaces ?? {}) + .find(([, accountId]) => accountId === "private-internal-id"); + + expect(resp!.status).toBe(200); + expect(body.catalogRefreshPending).toBe(false); + expect(publicBinding?.[0]).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(publicBinding?.[0]).not.toContain("private-internal-id"); + expect(refreshSpy).toHaveBeenCalledTimes(1); + } finally { + refreshSpy.mockRestore(); + } + }); + + test("catalog refresh failure retries and returns recoverable pending state after persistence", async () => { + enableManualImport(); + mockCodexWarmupSuccess(); + const config = makeConfig({ + codexAccountNamespaces: { main: "main" }, + codexAccountPickerEnabled: true, + }); + const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog") + .mockRejectedValue(new Error("private failure details")); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + try { + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: "persisted-despite-refresh" })), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const body = await resp!.json() as { ok?: boolean; catalogRefreshPending?: boolean }; + + expect(resp!.status).toBe(200); + expect(body).toEqual({ ok: true, catalogRefreshPending: true }); + expect(config.codexAccounts?.some(account => account.id === "persisted-despite-refresh")).toBe(true); + expect(getCodexAccountCredential("persisted-despite-refresh")).not.toBeNull(); + expect(refreshSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0]?.[0])).not.toContain("private failure details"); + } finally { + warnSpy.mockRestore(); + refreshSpy.mockRestore(); + } + }); + + test("re-adding a deleted account refreshes its retained picker binding", async () => { + enableManualImport(); + mockCodexWarmupSuccess(); + const config = makeConfig({ + codexAccountNamespaces: { side: "manual-test" }, + codexAccountPickerEnabled: true, + }); + const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog").mockResolvedValue({ + added: 0, + path: join(TEST_CODEX_HOME, "opencodex-catalog.json"), + catalogExists: false, + cacheSynced: false, + }); + try { + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody()), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(resp!.status).toBe(200); + expect(config.codexAccountNamespaces).toEqual({ side: "manual-test" }); + expect(visibleCodexAccountNamespaces(config)).toEqual({ side: "manual-test" }); + expect(refreshSpy).toHaveBeenCalledTimes(1); + } finally { + refreshSpy.mockRestore(); + } + }); + test("POST /api/codex-auth/accounts allows a pool account matching the main login", async () => { enableManualImport(); mockCodexWarmupSuccess(); @@ -879,8 +996,19 @@ describe("codex-auth API", () => { test("DELETE /api/codex-auth/accounts clears deleted active account from live runtime config", async () => { const config = makeConfig({ + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, activeCodexAccountId: "pool-delete", codexAccounts: [{ id: "pool-delete", email: "pool-delete@example.test", isMain: false }], + codexAccountNamespaces: { side: "pool-delete", main: "main" }, + codexAccountPickerEnabled: true, }); saveCodexAccountCredential("pool-delete", { accessToken: "access-delete", @@ -921,6 +1049,8 @@ describe("codex-auth API", () => { expect(resp!.status).toBe(200); expect(config.codexAccounts).toEqual([]); expect(config.activeCodexAccountId).toBeUndefined(); + expect(config.codexAccountNamespaces).toEqual({ side: "pool-delete", main: "main" }); + expect(visibleCodexAccountNamespaces(config)).toEqual({ main: "main" }); expect(getCodexAccountCredential("pool-delete")).toBeNull(); expect(getAccountQuota("pool-delete")).toBeNull(); expect(isAccountNeedsReauth("pool-delete")).toBe(false); @@ -929,6 +1059,38 @@ describe("codex-auth API", () => { expect(cancelled).toBe(true); expect(closed).toEqual([{ code: 4001, reason: "Codex account invalidated" }]); expect(getTrackedCodexWebSocketCountForAccount("pool-delete")).toBe(0); + + const routed = routeModel(config, "side/gpt-5.5"); + expect(routed).toMatchObject({ + modelId: "gpt-5.5", + codexAccountId: "pool-delete", + codexAccountNamespace: "side", + }); + await expect(resolveCodexAuthContext( + new Headers({ authorization: "Bearer caller" }), + config, + routed.codexAccountMode!, + { accountId: routed.codexAccountId }, + )).rejects.toBeInstanceOf(CodexPoolAuthenticationError); + }); + + test("account deletion signals a catalog refresh only when a visible binding disappears", () => { + const visible = makeConfig({ + codexAccounts: [{ id: "side-id", email: "side@example.test", isMain: false }], + codexAccountNamespaces: { side: "side-id" }, + codexAccountPickerEnabled: true, + }); + expect(deleteCodexAccount(visible, "side-id")).toBe(true); + expect(visible.codexAccountNamespaces).toEqual({ side: "side-id" }); + expect(visibleCodexAccountNamespaces(visible)).toEqual({}); + + const hidden = makeConfig({ + codexAccounts: [{ id: "side-id", email: "side@example.test", isMain: false }], + codexAccountNamespaces: { side: "side-id" }, + codexAccountPickerEnabled: false, + }); + expect(deleteCodexAccount(hidden, "side-id")).toBe(false); + expect(hidden.codexAccountNamespaces).toEqual({ side: "side-id" }); }); test("GET /api/codex-auth/login-status returns idle by default", async () => { @@ -1130,6 +1292,44 @@ describe("codex-auth API", () => { expect(data.error).toContain("Invalid account id"); }); + test("POST /api/codex-auth/login reserves main account ids before OAuth starts", async () => { + for (const id of ["main", "__main__"]) { + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig()); + expect(resp!.status).toBe(400); + expect(await resp!.json()).toMatchObject({ + error: "Account id is reserved for the main Codex login", + }); + } + }); + + test("POST /api/codex-auth/login allows legacy reserved pool ids to reauthenticate", async () => { + const oauth = await import("../src/oauth"); + const startSpy = spyOn(oauth, "startLoginFlow").mockRejectedValue(new Error("test-stop")); + try { + for (const id of ["main", "__main__"]) { + const config = makeConfig({ + codexAccounts: [{ id, email: `${id}@example.test`, isMain: false }], + }); + const req = new Request("http://localhost/api/codex-auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, reauth: true }), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(500); + expect(await resp!.json()).toMatchObject({ error: "test-stop" }); + } + expect(startSpy).toHaveBeenCalledTimes(2); + } finally { + startSpy.mockRestore(); + } + }); + test("POST /api/codex-auth/login rejects duplicate account id before OAuth starts", async () => { saveCodexAccountCredential("existing", { accessToken: "tok", diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index dcfb681b3..1bdafddbe 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -97,6 +97,33 @@ describe("Codex auth context", () => { await expect(resolveCodexAuthContext(new Headers({ authorization: "Bearer " }), config(), "direct")) .rejects.toBeInstanceOf(CodexDirectAuthenticationError); }); + test("fixed account resolution takes precedence over direct mode", async () => { + saveCodexAccountCredential("pool-a", { + accessToken: "fixed_pool_token", + refreshToken: "fixed_pool_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "fixed_pool_acc", + }); + + await expect(resolveCodexAuthContext(new Headers(), config(), "direct", { accountId: "pool-a" })) + .resolves.toMatchObject({ + kind: "pool", + accountId: "pool-a", + accessToken: "fixed_pool_token", + chatgptAccountId: "fixed_pool_acc", + fixedAccount: true, + }); + }); + + test("fixed account resolution in direct mode fails closed instead of using caller auth", async () => { + await expect(resolveCodexAuthContext( + new Headers({ authorization: "Bearer caller_token" }), + config(), + "direct", + { accountId: "missing-account" }, + )).rejects.toBeInstanceOf(CodexPoolAuthenticationError); + }); + test("selects pool auth independently of the routed provider", async () => { saveCodexAccountCredential("pool-a", { accessToken: "pool_token", @@ -164,6 +191,110 @@ describe("Codex auth context", () => { )).rejects.toBeInstanceOf(CodexPoolAuthenticationError); }); + test("fixed account resolution bypasses active selection and returns an immutable binding", async () => { + const cfg = config(); + cfg.activeCodexAccountId = "pool-b"; + saveCodexAccountCredential("pool-a", { + accessToken: "fixed_pool_token", + refreshToken: "fixed_pool_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "fixed_pool_acc", + }); + + await expect(resolveCodexAuthContext(new Headers({ + "x-codex-parent-thread-id": "fixed-thread", + }), cfg, "pool", { accountId: "pool-a" })).resolves.toMatchObject({ + kind: "pool", + accountId: "pool-a", + accessToken: "fixed_pool_token", + chatgptAccountId: "fixed_pool_acc", + fixedAccount: true, + }); + expect(cfg.activeCodexAccountId).toBe("pool-b"); + }); + + test("fixed main resolution reads the Codex Desktop login without consulting the active pool", async () => { + const cfg = config(); + cfg.activeCodexAccountId = "pool-a"; + writeFileSync(join(testDir, "auth.json"), JSON.stringify({ + tokens: { access_token: "opaque-live-main-token", account_id: "main-chatgpt-account" }, + })); + + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "__main__" })) + .resolves.toMatchObject({ + kind: "main-pool", + accountId: "__main__", + accessToken: "opaque-live-main-token", + chatgptAccountId: "main-chatgpt-account", + fixedAccount: true, + }); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + }); + + test("fixed account auth failure never falls back to another usable account", async () => { + const cfg = config(); + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + saveCodexAccountCredential("pool-b", { + accessToken: "pool_b_token", + refreshToken: "pool_b_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_b_acc", + }); + + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a" })) + .rejects.toBeInstanceOf(CodexPoolAuthenticationError); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + }); + + test("fixed cooled account fails closed without changing the active account", async () => { + const cfg = config(); + cfg.activeCodexAccountId = "pool-a"; + cfg.codexAccounts?.push({ id: "pool-b", email: "pool-b@example.test", isMain: false }); + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { retryAfter: "60", fixedAccount: true }); + + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a" })) + .rejects.toBeInstanceOf(CodexAccountCooldownError); + expect(cfg.activeCodexAccountId).toBe("pool-a"); + }); + + test("fixed account never consumes a reset-derived cooldown probe", async () => { + const cfg = config(); + const originalNow = Date.now; + const now = 1_800_000_000_000; + try { + Date.now = () => now; + saveCodexAccountCredential("pool-a", { + accessToken: "pool_a_token", + refreshToken: "pool_a_refresh", + expiresAt: now + 24 * 60 * 60_000, + chatgptAccountId: "pool_a_acc", + }); + recordCodexUpstreamOutcome(cfg, "pool-a", 429, { + resetAt: Math.floor((now + 60 * 60_000) / 1000), + now, + fixedAccount: true, + }); + + // At this point an ordinary pool request is eligible for the one probe + // lease. An exact-account request must fail without consuming it. + Date.now = () => now + CODEX_QUOTA_PROBE_INTERVAL_MS; + await expect(resolveCodexAuthContext(new Headers(), cfg, "pool", { accountId: "pool-a" })) + .rejects.toBeInstanceOf(CodexAccountCooldownError); + + const ordinaryPoolProbe = await resolveCodexAuthContext(new Headers(), cfg, "pool"); + expect(ordinaryPoolProbe.kind).toBe("pool"); + expect(ordinaryPoolProbe.kind === "pool" ? ordinaryPoolProbe.probeLeaseId : undefined).toBeTruthy(); + } finally { + Date.now = originalNow; + } + }); + test("selected pool headers replace inbound main auth", () => { const headers = headersForCodexAuthContext( new Headers({ authorization: "Bearer main_token", "chatgpt-account-id": "main_acc", "openai-beta": "responses=experimental" }), diff --git a/tests/codex-auth-modal-status.test.ts b/tests/codex-auth-modal-status.test.ts index c8be5a9e0..4d2e6dfdf 100644 --- a/tests/codex-auth-modal-status.test.ts +++ b/tests/codex-auth-modal-status.test.ts @@ -6,6 +6,8 @@ describe("Codex auth modal status feedback", () => { expect(source).toContain('useState<"idle" | "submitting" | "waiting">("idle")'); expect(source).toContain('setStatusNotice(t("codexAuth.oauthCodeSubmitted"))'); expect(source).toContain('setStatusNotice(t("codexAuth.oauthStatusRetrying"))'); + expect(source).toContain("catalogRefreshPending?: boolean"); + expect(source).toContain("onAddedRef.current(st.catalogRefreshPending === true)"); expect(source).toContain('disabled={manualCodeBusy || manualCodeWaiting || !manualCode.trim() || !flowId}'); expect(source).toContain('aria-live="polite"'); }); @@ -24,6 +26,7 @@ describe("Codex auth modal status feedback", () => { expect(source).toContain('"codexAuth.oauthSubmittingCode"'); expect(source).toContain('"codexAuth.oauthCodeSubmitted"'); expect(source).toContain('"codexAuth.oauthStatusRetrying"'); + expect(source).toContain('"codexAuth.catalogRefreshPending"'); } }); }); diff --git a/tests/codex-catalog-sync-hardening.test.ts b/tests/codex-catalog-sync-hardening.test.ts index e1737af02..73c7433eb 100644 --- a/tests/codex-catalog-sync-hardening.test.ts +++ b/tests/codex-catalog-sync-hardening.test.ts @@ -118,6 +118,65 @@ describe("Codex catalog sync hardening", () => { expect(slugs).toContain("gpt-5.5"); }); + test("empty routed discovery preserves provider rows while account-bound rows refresh independently", () => { + const catalogPath = join(codexHome, "catalog.json"); + writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); + writeFileSync(catalogPath, JSON.stringify({ + models: [ + nativeEntry("gpt-5.5", 0), + routedEntry("vendor/stable-model", 5), + { + ...routedEntry("side/gpt-5.5", 6), + display_name: "Stale provider row with a colliding slug", + }, + { + ...nativeEntry("retired/gpt-5.5", 7), + description: "OpenAI native model bound to a Codex account namespace.", + }, + ], + }, null, 2) + "\n"); + + const enabledConfig = `{ + providers: {}, + codexAccounts: [{ id: "side-account-id", email: "side@example.test", isMain: false }], + codexAccountNamespaces: { main: "main", side: "side-account-id" }, + codexAccountPickerEnabled: true + }`; + const enabled = runScript(codexHome, opencodexHome, ` + const { syncCatalogModels } = require("./src/codex/catalog"); + syncCatalogModels(${enabledConfig}).then(res => console.log(JSON.stringify(res))); + `); + expect(enabled.status).toBe(0); + expect(enabled.stderr).toContain("routed model fetch returned empty; preserving 1 existing routed entry"); + + let rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ + slug: string; + display_name?: string; + description?: string; + }>; + expect(rows.some(row => row.slug === "vendor/stable-model")).toBe(true); + expect(rows.some(row => row.slug === "retired/gpt-5.5")).toBe(false); + expect(rows.filter(row => row.slug === "side/gpt-5.5")).toHaveLength(1); + expect(rows.find(row => row.slug === "side/gpt-5.5")?.display_name).toBe("Side / 5.5"); + expect(rows.find(row => row.slug === "side/gpt-5.5")?.description) + .toBe("OpenAI native model bound to a Codex account namespace."); + expect(rows.some(row => row.slug === "main/gpt-5.5")).toBe(true); + + const disabled = runScript(codexHome, opencodexHome, ` + const { syncCatalogModels } = require("./src/codex/catalog"); + syncCatalogModels({ ...${enabledConfig}, codexAccountPickerEnabled: false }) + .then(res => console.log(JSON.stringify(res))); + `); + expect(disabled.status).toBe(0); + + rows = JSON.parse(readFileSync(catalogPath, "utf8")).models as Array<{ + slug: string; + description?: string; + }>; + expect(rows.some(row => row.slug === "vendor/stable-model")).toBe(true); + expect(rows.some(row => row.description === "OpenAI native model bound to a Codex account namespace.")).toBe(false); + }); + test("empty routed refresh drops compatibility-excluded rows while preserving other routed entries", () => { const catalogPath = join(codexHome, "catalog.json"); writeFileSync(join(codexHome, "config.toml"), 'model_catalog_json = "catalog.json"\n', "utf8"); diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index e2bcffd18..57ef0a705 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1057,6 +1057,51 @@ describe("Codex catalog routed normalization", () => { expect(native?.service_tiers).toEqual([{ id: "priority" }]); }); + test("buildCatalogEntries exposes account-qualified clones with native metadata", () => { + const entries = buildCatalogEntries( + nativeTemplate(), + ["gpt-5.5"], + [], + undefined, + false, + "default", + new Set(), + { main: "main", side: "opaque-account-id" }, + ); + const native = entries.find(entry => entry.slug === "gpt-5.5"); + const main = entries.find(entry => entry.slug === "main/gpt-5.5"); + const side = entries.find(entry => entry.slug === "side/gpt-5.5"); + + expect(main).toBeDefined(); + expect(side).toBeDefined(); + expect(main?.description).toBe("OpenAI native model bound to a Codex account namespace."); + expect(main?.model_messages).toEqual(native?.model_messages); + expect(main?.supported_reasoning_levels).toEqual(native?.supported_reasoning_levels); + expect(main?.input_modalities).toEqual(native?.input_modalities); + expect(main?.service_tiers).toEqual(native?.service_tiers); + expect(main?.display_name).toBe("Main / 5.5"); + expect(native?.visibility).toBe("hide"); + expect(Number.isInteger(main?.priority)).toBe(true); + expect(JSON.stringify(entries)).not.toContain("opaque-account-id"); + }); + + test("account-qualified clones enter the subagent priority window only when featured", () => { + const entries = buildCatalogEntries( + nativeTemplate(), + ["gpt-5.5"], + [], + ["side/gpt-5.5"], + false, + "default", + new Set(), + { main: "main", side: "side-account-id" }, + ); + expect(entries.find(entry => entry.slug === "side/gpt-5.5")?.priority).toBe(0); + expect(entries.find(entry => entry.slug === "main/gpt-5.5")?.priority).toBeGreaterThan(0); + expect(entries.find(entry => entry.slug === "gpt-5.5")?.priority).toBe(9); + expect(entries.find(entry => entry.slug === "gpt-5.5")?.visibility).toBe("hide"); + }); + test("catalog sync keeps native OpenAI rows when adopted providers expose matching ids", () => { const native = nativeTemplate(); const nativeMini = { diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index ebd861ef1..1c9ba2a0f 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -114,6 +114,14 @@ describe("codex routing", () => { expect(computeCodexUsageScore({ weeklyPercent: 15 })).toBe(15); }); + test("fixed-account failures record health without changing pool selection", () => { + const config = makeConfig({ upstreamFailoverThreshold: 1, activeCodexAccountId: "a" }); + recordCodexUpstreamOutcome(config, "a", 503, { fixedAccount: true }); + + expect(getCodexUpstreamHealth("a")).toMatchObject({ consecutiveFailures: 1 }); + expect(config.activeCodexAccountId).toBe("a"); + }); + test("go and free plans use only the 30d quota window", () => { expect(computeCodexUsageScore({ weeklyPercent: 99, monthlyPercent: 12 }, "go")).toBe(12); expect(computeCodexUsageScore({ weeklyPercent: 99, monthlyPercent: 13 }, "free")).toBe(13); diff --git a/tests/codex-v2-gate.test.ts b/tests/codex-v2-gate.test.ts index dbe3bd659..ddcf5af4b 100644 --- a/tests/codex-v2-gate.test.ts +++ b/tests/codex-v2-gate.test.ts @@ -543,6 +543,23 @@ describe("3-state multi-agent mode", () => { expect(native.multi_agent_version).toBeUndefined(); }); + test("mode default preserves upstream pins on account-qualified native clones", () => { + const entries = buildCatalogEntries( + template(), + ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"], + [], + [], + false, + "default", + new Set(), + { side: "side-account-id" }, + ); + + expect(entries.find(entry => entry.slug === "side/gpt-5.6-sol")?.multi_agent_version).toBe("v2"); + expect(entries.find(entry => entry.slug === "side/gpt-5.6-terra")?.multi_agent_version).toBe("v2"); + expect(entries.find(entry => entry.slug === "side/gpt-5.6-luna")?.multi_agent_version).toBe("v1"); + }); + test("mode v1 in mergeCatalogEntriesForSync overrides preserved genuine native", () => { const diskSol = { ...template(), diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 5bbf5a0af..e3947f36e 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -294,6 +294,25 @@ describe("combo management API", () => { }); }); + test("PUT rejects aliases owned by a Codex account namespace without mutating config", async () => { + await withTempHome(async () => { + const config = baseConfig({ codexAccountNamespaces: { side: "side-account-id" } }); + saveConfig(config); + + const response = await comboApi(config, "PUT", "/api/combos", { + id: "intentional", + combo: { ...VALID_COMBO, alias: "side/gpt-5.5" }, + }); + + expect(response?.status).toBe(400); + expect(await responseJson(response)).toEqual({ + error: "combo alias must not use a configured Codex account namespace", + }); + expect(config.combos?.intentional).toBeUndefined(); + expect(readConfigDiagnostics().config.codexAccountNamespaces).toEqual({ side: "side-account-id" }); + }); + }); + test("PUT rejects invalid and duplicate aliases without memory or disk mutation", async () => { await withTempHome(async () => { const config = baseConfig({ @@ -709,6 +728,56 @@ describe("combo management API", () => { }); }, 15_000); + test("catalog sync keeps bare combo aliases alongside account-qualified native rows", async () => { + await withTempHome(async dir => { + const previousCodexHome = process.env.CODEX_HOME; + const codexHome = join(dir, "codex-home"); + mkdirSync(codexHome, { recursive: true }); + process.env.CODEX_HOME = codexHome; + const catalogPath = join(codexHome, "opencodex-catalog.json"); + writeFileSync(catalogPath, JSON.stringify({ models: [{ + slug: "gpt-5.5", + display_name: "GPT-5.5", + visibility: "list", + base_instructions: "You are Codex.", + supported_reasoning_levels: [{ effort: "low" }, { effort: "xhigh" }], + input_modalities: ["text"], + context_window: 128_000, + }] })); + try { + const config = baseConfig({ + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://a.example/v1", + apiKey: "ka", + liveModels: false, + models: ["m1"], + modelContextWindows: { m1: 128_000 }, + modelInputModalities: { m1: ["text"] }, + modelReasoningEfforts: { m1: ["low"] }, + }, + }, + combos: { + fast: { alias: "fast-chat", targets: [{ provider: "a", model: "m1" }] }, + }, + codexAccountNamespaces: { side: "main" }, + }); + + await syncCatalogModels(config); + const catalog = JSON.parse(readFileSync(catalogPath, "utf8")) as { + models: Array<{ slug?: string }>; + }; + const slugs = catalog.models.map(model => model.slug); + expect(slugs).toContain("fast-chat"); + expect(slugs).toContain("side/gpt-5.5"); + } finally { + if (previousCodexHome === undefined) delete process.env.CODEX_HOME; + else process.env.CODEX_HOME = previousCodexHome; + } + }); + }, 15_000); + test("provider deletion is guarded by sorted combo dependencies until cleanup", async () => { await withTempHome(async () => { const config = baseConfig({ diff --git a/tests/config.test.ts b/tests/config.test.ts index cfd179caa..0aeae32af 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -779,6 +779,110 @@ describe("opencodex config defaults", () => { expect(isValidProviderName("constructor")).toBe(false); }); + test("Codex account namespaces persist when valid", () => { + writeConfig({ + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + codexAccountNamespaces: { main: "main", side: "side-account-id" }, + codexAccountPickerEnabled: false, + }); + + expect(loadConfig().codexAccountNamespaces).toEqual({ main: "main", side: "side-account-id" }); + expect(loadConfig().codexAccountPickerEnabled).toBe(false); + }); + + test("Codex account namespace visibility override must be boolean", () => { + writeConfig({ + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + codexAccountNamespaces: { main: "main" }, + codexAccountPickerEnabled: "yes", + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(loadConfig()).toEqual(getDefaultConfig()); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("codexAccountPickerEnabled must be a boolean")); + } finally { + errorSpy.mockRestore(); + } + }); + + test("Codex account namespaces cannot collide with provider routing", () => { + writeConfig({ + port: 10100, + providers: { + side: { adapter: "openai-chat", baseUrl: "https://side.example.test/v1" }, + }, + defaultProvider: "side", + codexAccountNamespaces: { side: "side-account-id" }, + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(loadConfig()).toEqual(getDefaultConfig()); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("must not collide")); + } finally { + errorSpy.mockRestore(); + } + }); + + test("Codex account namespaces cannot claim combo routing", () => { + writeConfig({ + port: 10100, + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + defaultProvider: "openai", + codexAccountNamespaces: { combo: "side-account-id" }, + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(loadConfig()).toEqual(getDefaultConfig()); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("combo namespaces")); + } finally { + errorSpy.mockRestore(); + } + }); + + test("Codex account namespaces cannot overlap an existing combo alias prefix", () => { + writeConfig({ + port: 10100, + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + defaultProvider: "openai", + combos: { + intentional: { + alias: "side/gpt-5.5", + targets: [{ provider: "openai", model: "gpt-5.5" }], + }, + }, + codexAccountNamespaces: { side: "side-account-id" }, + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(loadConfig()).toEqual(getDefaultConfig()); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining( + "combo aliases must not use a configured Codex account namespace", + )); + } finally { + errorSpy.mockRestore(); + } + }); + test("backs up config when defaultProvider only exists on Object prototype", () => { writeConfig({ port: 10100, diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 94f7d3d30..d8f112eea 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -480,6 +480,42 @@ describe("provider management validation", () => { } }); + test("provider management rejects names owned by a Codex account namespace", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const cfg = { + ...config("127.0.0.1"), + codexAccountNamespaces: { side: "side-account-id" }, + }; + saveConfig(cfg); + + const requestUrl = new URL("http://127.0.0.1/api/providers"); + const response = await handleManagementAPI( + new Request(requestUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "side", + provider: { + adapter: "openai-chat", + baseUrl: "https://side.example.test/v1", + }, + }), + }), + requestUrl, + cfg, + { refreshCodexCatalog: async () => {} }, + ); + + expect(response?.status).toBe(400); + expect(await response?.json()).toEqual({ + error: "provider name must not collide with a configured Codex account namespace", + }); + expect(cfg.providers.side).toBeUndefined(); + expect(loadConfig().codexAccountNamespaces).toEqual({ side: "side-account-id" }); + }); + test("provider management rejects base URLs with embedded credentials", async () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index 5b55f6274..d04f269ae 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -40,6 +40,7 @@ type CatalogFixtureModel = { visibility?: "list" | "hide"; priority?: number; multiAgentVersion?: "v1" | "v2" | null; + description?: string; }; /** Write an injected-catalog fixture into the active CODEX_HOME. */ @@ -51,6 +52,7 @@ function catalogFixture(dir: string, models: CatalogFixtureModel[]): void { visibility: model.visibility ?? "list", priority: model.priority ?? index, multi_agent_version: model.multiAgentVersion === undefined ? "v2" : model.multiAgentVersion, + ...(model.description ? { description: model.description } : {}), supported_reasoning_levels: (model.efforts ?? []) .map(effort => ({ effort, description: effort })), })), @@ -156,6 +158,83 @@ describe("multiAgentGuidanceText", () => { } }); + test("bare subagent choices project onto visible account-qualified guidance rows only", async () => { + const dir = codexHomeFixture(V2_ON); + const boundDescription = "OpenAI native model bound to a Codex account namespace."; + catalogFixture(dir, [ + { slug: "gpt-5.6-sol", visibility: "hide", priority: 0, multiAgentVersion: "v2" }, + { slug: "vendor/gpt-5.6-sol", priority: 0, multiAgentVersion: "v2" }, + { slug: "main/gpt-5.6-sol", priority: 1, multiAgentVersion: "v2", description: boundDescription }, + { slug: "side/gpt-5.6-sol", priority: 2, multiAgentVersion: "v2", description: boundDescription }, + ]); + + const effective = effectiveSubagentRoster(["gpt-5.6-sol"], "v2"); + expect(effective.advertised.map(model => model.model)).toEqual([ + "main/gpt-5.6-sol", + "side/gpt-5.6-sol", + ]); + expect(effective.excluded).toEqual([]); + + const text = await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { subagentModels: ["gpt-5.6-sol"] }, + ); + expect(text).toContain('Available models (valid reasoning_effort): "main/gpt-5.6-sol", "side/gpt-5.6-sol".'); + expect(text).not.toContain('"vendor/gpt-5.6-sol"'); + }); + + test("bare injection choice projects onto an account-qualified preferred model", async () => { + const dir = codexHomeFixture(V2_ON); + const boundDescription = "OpenAI native model bound to a Codex account namespace."; + catalogFixture(dir, [ + { slug: "gpt-5.6-sol", visibility: "hide", priority: 0, multiAgentVersion: "v2" }, + { slug: "vendor/gpt-5.6-sol", priority: 0, multiAgentVersion: "v2" }, + { + slug: "account-2/gpt-5.6-sol", + efforts: ["high", "max"], + priority: 1, + multiAgentVersion: "v2", + description: boundDescription, + }, + ]); + + const text = await multiAgentGuidanceText( + parsedFixture({ tools: [{ name: "spawn_agent" }] }), + { injectionModel: "gpt-5.6-sol", injectionEffort: "max" }, + ); + expect(text).toContain( + 'Preferred sub-agent: model "account-2/gpt-5.6-sol", reasoning_effort "max"', + ); + expect(text).not.toContain('Preferred sub-agent: model "vendor/gpt-5.6-sol"'); + }); + + test("bare choices report an account-qualified replacement outside the display limit", () => { + const dir = codexHomeFixture(V2_ON); + const boundDescription = "OpenAI native model bound to a Codex account namespace."; + catalogFixture(dir, [ + { slug: "gpt-5.6-sol", visibility: "hide", priority: 0, multiAgentVersion: "v2" }, + { slug: "filler-a", priority: 1, multiAgentVersion: "v2" }, + { slug: "filler-b", priority: 2, multiAgentVersion: "v2" }, + { slug: "filler-c", priority: 3, multiAgentVersion: "v2" }, + { slug: "filler-d", priority: 4, multiAgentVersion: "v2" }, + { slug: "filler-e", priority: 5, multiAgentVersion: "v2" }, + { + slug: "main/gpt-5.6-sol", + priority: 6, + multiAgentVersion: "v2", + description: boundDescription, + }, + ]); + + const effective = effectiveSubagentRoster(["gpt-5.6-sol"], "v2"); + expect(effective.advertised).toEqual([]); + expect(effective.excluded).toEqual([{ + configured: "gpt-5.6-sol", + catalogModel: "main/gpt-5.6-sol", + reason: "outside_display_limit", + }]); + }); + test("effective roster applies alias, visibility, v2 compatibility, stable priority, cap, and diagnostics", async () => { const dir = codexHomeFixture(V2_ON); catalogFixture(dir, [ diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 68a9fbf9a..b8f59e686 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -1,6 +1,19 @@ import { describe, expect, test } from "bun:test"; +import { + CODEX_ACCOUNT_LOG_LABEL_RE, + fallbackCodexAccountLogLabel, +} from "../src/codex/account-label"; +import { + accountBoundNativeDisplayName, + appendDefaultCodexAccountNamespace, + codexAccountPickerHasVisibleRows, + codexAccountPickerIsEnabled, + defaultCodexAccountNamespaces, + visibleCodexAccountNamespaces, +} from "../src/codex/account-namespaces"; import { applyNativeVisibility, + buildCatalogEntries, disabledNativeSlugs, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, @@ -31,6 +44,155 @@ function nativeTemplate(): Record { } describe("native GPT model toggles (bare slugs in disabledModels)", () => { + test("account replacement labels title-case synthesized slug words", () => { + expect(accountBoundNativeDisplayName("side-account", { slug: "gpt-5.3-codex-spark" })) + .toBe("Side-Account / 5.3 Codex Spark"); + }); + + test("account replacement labels preserve separators that distinguish local ids", () => { + expect([ + accountBoundNativeDisplayName("team-prod", { slug: "gpt-5.5" }), + accountBoundNativeDisplayName("team_prod", { slug: "gpt-5.5" }), + accountBoundNativeDisplayName("team.prod", { slug: "gpt-5.5" }), + ]).toEqual([ + "Team-Prod / 5.5", + "Team_Prod / 5.5", + "Team.Prod / 5.5", + ]); + }); + + test("UI defaults use public aliases or privacy-safe labels without exposing stored account ids", () => { + const namespaces = defaultCodexAccountNamespaces({ + providers: { + side: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + codexAccounts: [ + { id: "private-side-id", email: "private@example.test", alias: "Side", logLabel: "p111111", isMain: false }, + { id: "private-team-id", email: "other@example.test", logLabel: "p222222", isMain: false }, + ], + }); + expect(namespaces).toEqual({ main: "main", "side-2": "private-side-id", p222222: "private-team-id" }); + expect(Object.keys(namespaces).join(",")).not.toContain("private-side-id"); + expect(Object.keys(namespaces).join(",")).not.toContain("private-team-id"); + expect(Object.keys(namespaces).join(",")).not.toContain("example.test"); + }); + + test("legacy accounts without log labels get independent random public selectors", () => { + const internalId = "private-internal-id"; + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: internalId, email: "private@example.test", isMain: false }, + ], + }); + const selector = Object.entries(namespaces) + .find(([, accountId]) => accountId === internalId)?.[0]; + + expect(selector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(selector).not.toBe(fallbackCodexAccountLogLabel(internalId)); + expect(selector).not.toContain(internalId); + + const enabled = { + providers: {}, + codexAccountNamespaces: { main: "main" } as Record, + }; + expect(appendDefaultCodexAccountNamespace(enabled, { id: internalId, isMain: false })).toBe(true); + const appendedSelector = Object.entries(enabled.codexAccountNamespaces) + .find(([, accountId]) => accountId === internalId)?.[0]; + expect(appendedSelector).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(appendedSelector).not.toBe(fallbackCodexAccountLogLabel(internalId)); + }); + + test("UI defaults avoid prefixes already owned by combo aliases", () => { + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + combos: { + primary: { alias: "main/gpt-5.5", targets: [{ provider: "openai", model: "gpt-5.5" }] }, + secondary: { alias: "side/gpt-5.6-sol", targets: [{ provider: "openai", model: "gpt-5.6-sol" }] }, + }, + codexAccounts: [ + { id: "private-side-id", email: "side@example.test", alias: "Side", logLabel: "p111111", isMain: false }, + ], + }); + expect(namespaces).toEqual({ "main-2": "main", "side-2": "private-side-id" }); + }); + + test("new accounts append to an enabled map without renaming existing prefixes", () => { + const config = { + providers: { "side-id": { adapter: "openai-chat" as const, baseUrl: "https://example.test/v1" } }, + codexAccountNamespaces: { main: "main", legacy: "legacy-id" }, + }; + expect(appendDefaultCodexAccountNamespace(config, { + id: "side-id", + alias: "Side", + logLabel: "p111111", + isMain: false, + })).toBe(true); + expect(config.codexAccountNamespaces).toEqual({ + main: "main", + legacy: "legacy-id", + side: "side-id", + }); + expect(appendDefaultCodexAccountNamespace(config, { + id: "side-id", + alias: "Side", + logLabel: "p111111", + isMain: false, + })).toBe(false); + }); + + test("empty maps stay disabled and reserved main ids never become added-account selectors", () => { + const empty = { providers: {}, codexAccountNamespaces: {} as Record }; + expect(appendDefaultCodexAccountNamespace(empty, { id: "side", isMain: false })).toBe(false); + expect(empty.codexAccountNamespaces).toEqual({}); + + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + codexAccounts: [ + { id: "main", email: "reserved-one@example.test", isMain: false }, + { id: "__main__", email: "reserved-two@example.test", isMain: false }, + { id: "side", email: "side@example.test", logLabel: "p333333", isMain: false }, + ], + }); + expect(namespaces).toEqual({ main: "main", p333333: "side" }); + }); + + test("picker visibility defaults on for legacy maps and preserves dormant mappings", () => { + const legacy = { codexAccountNamespaces: { main: "main" } }; + expect(codexAccountPickerIsEnabled(legacy)).toBe(true); + expect(visibleCodexAccountNamespaces(legacy)).toEqual({ main: "main" }); + + const dormant = { + providers: {}, + codexAccountNamespaces: { main: "main" }, + codexAccountPickerEnabled: false, + }; + expect(codexAccountPickerIsEnabled(dormant)).toBe(false); + expect(visibleCodexAccountNamespaces(dormant)).toEqual({}); + expect(appendDefaultCodexAccountNamespace(dormant, { + id: "side", + logLabel: "p444444", + isMain: false, + })).toBe(true); + expect(dormant.codexAccountNamespaces).toEqual({ main: "main", p444444: "side" }); + expect(codexAccountPickerIsEnabled(dormant)).toBe(false); + }); + + test("stale-only picker bindings preserve intent without hiding bare native rows", () => { + const stale = { + codexAccounts: [], + codexAccountNamespaces: { removed: "removed-account" }, + codexAccountPickerEnabled: true, + }; + expect(codexAccountPickerIsEnabled(stale)).toBe(true); + expect(visibleCodexAccountNamespaces(stale)).toEqual({}); + expect(codexAccountPickerHasVisibleRows(stale)).toBe(false); + + const entries = [nativeTemplate()]; + applyNativeVisibility(entries, new Set(), codexAccountPickerHasVisibleRows(stale)); + expect(entries[0]?.visibility).toBe("list"); + }); + test("disabledNativeSlugs picks bare ids only; routed namespaced ids are ignored", () => { const set = disabledNativeSlugs({ disabledModels: ["gpt-5.4", "kiro/claude-opus-4.6", "gpt-5.6-luna"] }); expect([...set].sort()).toEqual(["gpt-5.4", "gpt-5.6-luna"]); @@ -108,6 +270,69 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(entries[1].visibility).toBe("list"); }); + test("applyNativeVisibility mirrors disabled native state onto account-qualified clones", () => { + const entries = [ + { + slug: "side/gpt-5.6-sol", + description: "OpenAI native model bound to a Codex account namespace.", + visibility: "list", + }, + ]; + applyNativeVisibility(entries, new Set(["gpt-5.6-sol"])); + expect(entries[0].visibility).toBe("hide"); + }); + + test("account namespaces hide bare rows and substitute friendly account-qualified pairs", () => { + const entries = buildCatalogEntries( + nativeTemplate(), + ["gpt-5.5"], + [{ provider: "litellm-local", id: "qwen3.6" }], + ["gpt-5.5"], + false, + "default", + new Set(), + { main: "main", side: "side-account-id" }, + ); + applyNativeVisibility(entries, new Set(), true); + + const bare = entries.find(entry => entry.slug === "gpt-5.5"); + const main = entries.find(entry => entry.slug === "main/gpt-5.5"); + const side = entries.find(entry => entry.slug === "side/gpt-5.5"); + const routed = entries.find(entry => entry.slug === "litellm-local/qwen3.6"); + expect(bare?.visibility).toBe("hide"); + expect(main).toMatchObject({ + display_name: "Main / 5.5", + visibility: "list", + priority: 0, + }); + expect(side?.display_name).toBe("Side / 5.5"); + expect(side?.visibility).toBe("list"); + expect(side?.priority).toBeGreaterThan(main?.priority as number); + expect(side?.priority).toBe(1); + expect(routed?.priority).toBeGreaterThan(side?.priority as number); + expect(entries.every(entry => Number.isInteger(entry.priority))).toBe(true); + }); + + test("catalog sync removes stale account-qualified rows when routed discovery is empty", () => { + const merged = mergeCatalogEntriesForSync( + [ + nativeTemplate(), + { + ...nativeTemplate(), + slug: "side/gpt-5.5", + description: "OpenAI native model bound to a Codex account namespace.", + }, + ], + [], + new Map(), + [], + false, + ); + + expect(merged.some(entry => entry.slug === "side/gpt-5.5")).toBe(false); + expect(merged.some(entry => entry.slug === "gpt-5.5")).toBe(true); + }); + test("management API surfaces: /api/models leads with native rows; subagent available drops disabled bare slugs", async () => { const config = makeConfig({ disabledModels: ["gpt-5.6-sol"] }); diff --git a/tests/provider-workspace-auth.test.ts b/tests/provider-workspace-auth.test.ts index 9377f2d92..0abbeeb46 100644 --- a/tests/provider-workspace-auth.test.ts +++ b/tests/provider-workspace-auth.test.ts @@ -113,6 +113,20 @@ describe("workspace account integration seam", () => { expect(codexPool).toContain('setToast(t("codexAuth.removeFailed"))'); }); + test("surfaces a saved account whose model catalog still needs refresh", async () => { + const [page, pool, modal] = await Promise.all([ + Bun.file("gui/src/pages/Providers.tsx").text(), + Bun.file("gui/src/components/CodexAccountPool.tsx").text(), + Bun.file("gui/src/components/AddCodexAccountModal.tsx").text(), + ]); + expect(modal).toContain("onAddedRef.current(st.catalogRefreshPending === true)"); + expect(page).toContain("onAdded={(catalogRefreshPending) =>"); + expect(page).toContain('t("codexAuth.catalogRefreshPending")'); + expect(page).toContain("!catalogRefreshPending"); + expect(pool).toContain('t(catalogRefreshPending ? "codexAuth.catalogRefreshPending"'); + expect(pool).toContain("result.catalogRefreshPending"); + }); + test("gives canonical Codex accounts explicit native switch actions", async () => { const source = await Bun.file("gui/src/components/CodexAccountPool.tsx").text(); expect(source).toContain("codex-account-switch"); diff --git a/tests/router.test.ts b/tests/router.test.ts index 34af7ebb6..7cc73a64c 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { mapReasoningEffort } from "../src/reasoning-effort"; import { NoEnabledOpenAiProviderError, routeModel } from "../src/router"; -import type { OcxConfig } from "../src/types"; +import type { OcxConfig, OcxProviderConfig } from "../src/types"; describe("routeModel registry effort defaults", () => { test("allows only opted-in OAuth presets to use explicit API-key billing", () => { @@ -67,6 +67,81 @@ describe("routeModel registry effort defaults", () => { }); }); + test("routes account-qualified native models to one exact Codex account", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + }, + }, + codexAccountNamespaces: { main: "main", side: "side-account-id" }, + }; + + expect(routeModel(config, "main/gpt-5.6-sol")).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.6-sol", + codexAccountMode: "pool", + codexAccountId: "__main__", + codexAccountNamespace: "main", + }); + expect(routeModel(config, "side/gpt-5.5")).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.5", + codexAccountMode: "pool", + codexAccountId: "side-account-id", + codexAccountNamespace: "side", + }); + expect(routeModel(config, "gpt-5.5")).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.5", + codexAccountMode: "direct", + }); + + config.codexAccountPickerEnabled = false; + expect(routeModel(config, "side/gpt-5.5")).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.5", + codexAccountId: "side-account-id", + codexAccountNamespace: "side", + }); + }); + + test("account namespaces reject non-native selectors instead of falling through", () => { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + codexAccountNamespaces: { side: "side-account-id" }, + }; + expect(() => routeModel(config, "side/claude-opus-4-6")).toThrow("only supports native OpenAI"); + }); + + test("account namespaces reject noncanonical OpenAI providers before enabling credential injection", () => { + const providers: OcxProviderConfig[] = [ + { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "key" as const }, + { adapter: "openai-chat", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, + { adapter: "openai-responses", baseUrl: "https://proxy.example.test/v1", authMode: "forward" as const }, + ]; + + for (const openai of providers) { + const config: OcxConfig = { + port: 10100, + defaultProvider: "openai", + providers: { openai }, + codexAccountNamespaces: { side: "side-account-id" }, + }; + + expect(() => routeModel(config, "side/gpt-5.5")).toThrow(NoEnabledOpenAiProviderError); + } + }); + test("routes a self-namespaced native id whole instead of stripping to the remainder", () => { const config: OcxConfig = { port: 10100, diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 743f2e255..2be7c3dda 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -27,6 +27,7 @@ import { startServer, } from "../src/server"; import { handleManagementAPI } from "../src/server/management-api"; +import { clearRequestLogsForTests, getRequestLogEntries } from "../src/server/request-log"; import type { OcxConfig } from "../src/types"; import { fakeChatGptJwt } from "./helpers/fake-chatgpt-jwt"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; @@ -123,14 +124,21 @@ function unsupportedModelBody(model = POOL_RETRY_MODEL): string { type PoolRetryHarness = { config: OcxConfig; dispatches: string[]; - request: (init?: { stream?: boolean; signal?: AbortSignal }) => Promise; + request: (init?: { stream?: boolean; signal?: AbortSignal; model?: string; reasoningEffort?: string }) => Promise; server: ReturnType; upstream: ReturnType; }; async function startPoolRetryHarness( reply: (accountId: string, request: Request) => Response | Promise, - options: { secondAccount?: boolean; streamMode?: "legacy-tee" | "eager-relay" } = {}, + options: { + secondAccount?: boolean; + streamMode?: "legacy-tee" | "eager-relay"; + accountNamespaces?: Record; + activeAccountId?: string; + accountMode?: "pool" | "direct"; + websockets?: boolean; + } = {}, ): Promise { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); @@ -153,11 +161,13 @@ async function startPoolRetryHarness( redirectCanonicalCodexTo(upstream.url.toString()); const secondAccount = options.secondAccount ?? true; + const providers = poolProviders(); + if (options.accountMode) providers.openai.codexAccountMode = options.accountMode; const config = { port: 0, defaultProvider: "openai", openaiProviderTierVersion: 2, - providers: poolProviders(), + providers, codexAccounts: [ { id: "main", email: "main@example.test", isMain: true }, { id: "pool-a", email: "pool-a@example.test", isMain: false, chatgptAccountId: "acct-pool-a" }, @@ -165,8 +175,10 @@ async function startPoolRetryHarness( ? [{ id: "pool-b", email: "pool-b@example.test", isMain: false, chatgptAccountId: "acct-pool-b" }] : []), ], - activeCodexAccountId: "pool-a", + activeCodexAccountId: options.activeAccountId ?? "pool-a", + ...(options.accountNamespaces ? { codexAccountNamespaces: options.accountNamespaces } : {}), ...(options.streamMode ? { streamMode: options.streamMode } : {}), + ...(options.websockets ? { websockets: true } : {}), } as OcxConfig; saveConfig(config); saveCodexAccountCredential("pool-a", { @@ -192,12 +204,17 @@ async function startPoolRetryHarness( dispatches, server, upstream, - request: ({ stream = false, signal } = {}) => originalGlobalFetch( + request: ({ stream = false, signal, model = POOL_RETRY_MODEL, reasoningEffort } = {}) => originalGlobalFetch( new URL("/v1/responses", server.url), { method: "POST", headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, - body: JSON.stringify({ model: POOL_RETRY_MODEL, input: "hello", stream }), + body: JSON.stringify({ + model, + input: "hello", + stream, + ...(reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}), + }), signal, }, ), @@ -294,6 +311,7 @@ describe("server local API auth", () => { test("safeConfigDTO redacts provider secrets and exposes booleans", () => { const unsafe = config("127.0.0.1"); unsafe.openaiProviderTierVersion = 1; + unsafe.codexAccountNamespaces = { main: "main", side: "private-side-account-id" }; Object.assign(unsafe.providers.openai as unknown as Record, { apiKeyPool: [{ id: "pool-id", key: "pool-secret", label: "private-pool-label" }], modelMaxInputTokens: { "gpt-test": 1000 }, @@ -307,6 +325,7 @@ describe("server local API auth", () => { }); const dto = safeConfigDTO(unsafe) as { providers: Record>; + codexAccountPickerEnabled: boolean; }; const serialized = JSON.stringify(dto); for (const forbidden of [ @@ -315,7 +334,9 @@ describe("server local API auth", () => { "virtualModels", "codexAuthContext", "selectedForwardHeaders", "sidecarOutcomeRecorder", "recorder-runtime", "_codexAccountOverride", "_codexAccountRequired", "runtime-token", "override-token", + "private-side-account-id", ]) expect(serialized).not.toContain(forbidden); + expect(dto.codexAccountPickerEnabled).toBe(true); expect(dto.providers.openai).toMatchObject({ adapter: "openai-chat", baseUrl: "https://api.example.test/v1", @@ -324,6 +345,9 @@ describe("server local API auth", () => { hasHeaders: true, codexAccountMode: "pool", }); + + unsafe.codexAccountPickerEnabled = false; + expect((safeConfigDTO(unsafe) as { codexAccountPickerEnabled: boolean }).codexAccountPickerEnabled).toBe(false); expect(dto.providers.openai).not.toHaveProperty("apiKey"); expect(dto.providers.openai).not.toHaveProperty("headers"); expect(dto.providers.openai.disabled).toBeUndefined(); @@ -1678,6 +1702,111 @@ describe("server local API auth", () => { } }); + test("account-qualified model preserves the selected account on an allow-listed 400", async () => { + const body = unsupportedModelBody(); + clearRequestLogsForTests(); + const harness = await startPoolRetryHarness( + () => rejectionResponse(body), + { accountNamespaces: { "public-selector": "pool-a" }, activeAccountId: "pool-b", accountMode: "direct" }, + ); + try { + await expectOriginal400(await harness.request({ model: `public-selector/${POOL_RETRY_MODEL}` }), body); + expect(harness.dispatches).toEqual(["acct-pool-a"]); + expect(harness.config.activeCodexAccountId).toBe("pool-b"); + expect(getCodexUpstreamHealth("pool-b")).toBeNull(); + const latest = getRequestLogEntries().at(-1); + expect(latest?.provider).toBe("openai-public-selector"); + expect(JSON.stringify(latest)).not.toContain("pool-a"); + } finally { + await stopPoolRetryHarness(harness); + clearRequestLogsForTests(); + } + }); + + test("account-qualified compact request uses the same exact account", async () => { + const harness = await startPoolRetryHarness( + () => Response.json({ output: [] }), + { accountNamespaces: { side: "pool-a" }, activeAccountId: "pool-b", accountMode: "direct" }, + ); + try { + const response = await originalGlobalFetch(new URL("/v1/responses/compact", harness.server.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: "Bearer inbound-token" }, + body: JSON.stringify({ model: `side/${POOL_RETRY_MODEL}`, input: [] }), + }); + expect(response.status).toBe(200); + expect(harness.dispatches).toEqual(["acct-pool-a"]); + expect(harness.config.activeCodexAccountId).toBe("pool-b"); + } finally { + await stopPoolRetryHarness(harness); + } + }); + + test("account-qualified old native models apply the native max effort clamp", async () => { + let upstreamEffort: unknown; + const harness = await startPoolRetryHarness( + async (_accountId, request) => { + const body = await request.json() as { reasoning?: { effort?: unknown } }; + upstreamEffort = body.reasoning?.effort; + return Response.json({ id: "clamped", status: "completed", output: [] }); + }, + { accountNamespaces: { side: "pool-a" }, activeAccountId: "pool-b", accountMode: "direct" }, + ); + try { + const response = await harness.request({ model: "side/gpt-5.5", reasoningEffort: "max" }); + expect(response.status).toBe(200); + expect(upstreamEffort).toBe("xhigh"); + expect(harness.dispatches).toEqual(["acct-pool-a"]); + expect(harness.config.activeCodexAccountId).toBe("pool-b"); + } finally { + await stopPoolRetryHarness(harness); + } + }); + + test("account-qualified WebSocket requests use the exact selected account", async () => { + const harness = await startPoolRetryHarness( + () => new Response([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"qualified-ws","status":"completed","output":[]}}\n\n', + ].join(""), { headers: { "content-type": "text/event-stream" } }), + { + accountNamespaces: { side: "pool-a" }, + activeAccountId: "pool-b", + accountMode: "direct", + websockets: true, + }, + ); + const wsUrl = new URL("/v1/responses", harness.server.url); + wsUrl.protocol = "ws:"; + const ws = new WebSocket(wsUrl); + try { + await new Promise((resolve, reject) => { + ws.addEventListener("open", () => resolve(), { once: true }); + ws.addEventListener("error", () => reject(new Error("account-qualified websocket failed to open")), { once: true }); + }); + const terminal = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("account-qualified websocket timeout")), 2_000); + ws.addEventListener("message", event => { + if (typeof event.data !== "string" || !event.data.includes('"type":"response.completed"')) return; + clearTimeout(timer); + resolve(); + }); + }); + ws.send(JSON.stringify({ + type: "response.create", + model: "side/gpt-5.5", + input: "hello", + })); + await terminal; + + expect(harness.dispatches).toEqual(["acct-pool-a"]); + expect(harness.config.activeCodexAccountId).toBe("pool-b"); + } finally { + ws.close(); + await stopPoolRetryHarness(harness); + } + }); + test("Activation B: allow-listed 400 with one eligible account preserves the original response", async () => { const body = unsupportedModelBody(); const harness = await startPoolRetryHarness(() => rejectionResponse(body), { secondAccount: false }); diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index d403f237d..df6adf527 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -33,13 +33,13 @@ function baseConfig(): OcxConfig { }; } -function putSettings(config: OcxConfig, body: unknown): Promise { +function putSettings(config: OcxConfig, body: unknown, refreshCodexCatalog?: () => Promise): Promise { const req = new Request("http://127.0.0.1:10100/api/settings", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify(body), }); - return handleManagementAPI(req, new URL(req.url), config); + return handleManagementAPI(req, new URL(req.url), config, { refreshCodexCatalog }); } function getSettings(config: OcxConfig): Promise { @@ -81,6 +81,26 @@ describe("GET /api/settings", () => { expect(body.streamMode).toBe("eager-relay"); }); + test("reports the effective account-picker state", async () => { + const visible = await (await getSettings({ + ...baseConfig(), + codexAccountNamespaces: { main: "main" }, + }))!.json() as { codexAccountPickerEnabled?: boolean }; + const hidden = await (await getSettings({ + ...baseConfig(), + codexAccountNamespaces: { main: "main" }, + codexAccountPickerEnabled: false, + }))!.json() as { codexAccountPickerEnabled?: boolean }; + const empty = await (await getSettings({ + ...baseConfig(), + codexAccountNamespaces: {}, + codexAccountPickerEnabled: true, + }))!.json() as { codexAccountPickerEnabled?: boolean }; + + expect([visible.codexAccountPickerEnabled, hidden.codexAccountPickerEnabled, empty.codexAccountPickerEnabled]) + .toEqual([true, false, false]); + }); + test("reports redacted codexRuntime diagnostics and clamp correlation", async () => { const { chmodSync } = await import("node:fs"); const { @@ -194,6 +214,111 @@ describe("PUT /api/settings", () => { const res = await putSettings(config, {}); expect(res!.status).toBe(400); }); + + test("rejects non-object JSON bodies without mutating config", async () => { + for (const input of [null, [], "codexAutoStart"]) { + const config = baseConfig(); + const before = structuredClone(config); + const res = await putSettings(config, input); + expect(res!.status).toBe(400); + expect(config).toEqual(before); + } + }); + + test("enabling account-specific models avoids combo alias prefixes and survives config reload", async () => { + const config: OcxConfig = { + ...baseConfig(), + codexAccounts: [ + { id: "side-id", email: "side@example.test", alias: "Side", logLabel: "p111111", plan: "business", isMain: false }, + ], + combos: { + primary: { + alias: "main/gpt-5.5", + targets: [{ provider: "openai", model: "gpt-test" }], + }, + secondary: { + alias: "side/gpt-5.6-sol", + targets: [{ provider: "openai", model: "gpt-test" }], + }, + }, + }; + let refreshes = 0; + const refresh = async () => { refreshes += 1; }; + + const enabled = await putSettings(config, { codexAccountPickerEnabled: true }, refresh); + expect(enabled!.status).toBe(200); + expect(config.codexAccountNamespaces).toEqual({ "main-2": "main", "side-2": "side-id" }); + expect(await enabled!.json()).toMatchObject({ + codexAccountPickerEnabled: true, + }); + expect(refreshes).toBe(1); + expect(loadConfig()).toMatchObject({ + codexAccountNamespaces: { "main-2": "main", "side-2": "side-id" }, + codexAccountPickerEnabled: true, + }); + }); + + test("an enabled picker with an empty map initializes bindings and refreshes once", async () => { + const config: OcxConfig = { + ...baseConfig(), + codexAccounts: [ + { id: "private-id", email: "private@example.test", logLabel: "p123456", isMain: false }, + ], + codexAccountNamespaces: {}, + codexAccountPickerEnabled: true, + }; + let refreshes = 0; + const response = await putSettings(config, { codexAccountPickerEnabled: true }, async () => { + refreshes += 1; + }); + + expect(response!.status).toBe(200); + expect(config.codexAccountNamespaces).toEqual({ main: "main", p123456: "private-id" }); + expect(refreshes).toBe(1); + }); + + test("account picker toggle preserves a custom ordered selector map exactly", async () => { + const selectors = { + "secondary-profile": "side-id", + "primary-profile": "main", + "break-glass": "emergency-id", + }; + const expectedEntries = Object.entries(selectors); + const config: OcxConfig = { + ...baseConfig(), + codexAccountNamespaces: { ...selectors }, + codexAccountPickerEnabled: true, + }; + let refreshes = 0; + const refresh = async () => { refreshes += 1; }; + + const disabled = await putSettings(config, { codexAccountPickerEnabled: false }, refresh); + expect(disabled!.status).toBe(200); + expect(Object.entries(config.codexAccountNamespaces ?? {})).toEqual(expectedEntries); + expect(config.codexAccountPickerEnabled).toBe(false); + expect(await disabled!.json()).toMatchObject({ + codexAccountPickerEnabled: false, + }); + expect(Object.entries(loadConfig().codexAccountNamespaces ?? {})).toEqual(expectedEntries); + expect(refreshes).toBe(1); + + const reenabled = await putSettings(config, { codexAccountPickerEnabled: true }, refresh); + expect(reenabled!.status).toBe(200); + expect(Object.entries(config.codexAccountNamespaces ?? {})).toEqual(expectedEntries); + expect(config.codexAccountPickerEnabled).toBe(true); + expect(await reenabled!.json()).toMatchObject({ + codexAccountPickerEnabled: true, + }); + const reloaded = loadConfig(); + expect(Object.entries(reloaded.codexAccountNamespaces ?? {})).toEqual(expectedEntries); + expect(reloaded.codexAccountPickerEnabled).toBe(true); + expect(refreshes).toBe(2); + }); + + test("account-specific model toggle rejects non-boolean values", async () => { + const config = baseConfig(); + expect((await putSettings(config, { codexAccountPickerEnabled: "yes" }))!.status).toBe(400); + }); }); describe("config.json schema resilience", () => {