From 71ec93ed0c598de347c87f53b42a6ade53456664 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Fri, 24 Jul 2026 17:28:21 -0400 Subject: [PATCH 01/15] Add account-qualified Codex model namespaces --- .../content/docs/guides/codex-app-models.md | 14 ++++ .../src/content/docs/guides/model-routing.md | 19 +++-- .../content/docs/reference/configuration.md | 30 ++++++++ src/codex/account-lifecycle.ts | 6 ++ src/codex/account-namespaces.ts | 26 +++++++ src/codex/auth-context.ts | 25 ++++++- src/codex/catalog/metadata.ts | 7 +- src/codex/catalog/sync.ts | 29 +++++++- src/codex/routing.ts | 14 ++-- src/config.ts | 48 ++++++++++++ src/router.ts | 28 ++++++- src/server/index.ts | 4 +- src/server/responses/compact.ts | 11 ++- src/server/responses/core.ts | 30 ++++++-- src/types.ts | 6 ++ structure/08_openai-provider-tiers.md | 17 +++++ tests/claude-models-discovery.test.ts | 34 +++++++++ tests/codex-auth-api.test.ts | 2 + tests/codex-auth-context.test.ts | 74 ++++++++++++++++++- tests/codex-catalog.test.ts | 42 +++++++++++ tests/codex-routing.test.ts | 8 ++ tests/config.test.ts | 53 +++++++++++++ tests/native-model-toggle.test.ts | 12 +++ tests/router.test.ts | 48 ++++++++++++ tests/server-auth.test.ts | 56 ++++++++++++-- 25 files changed, 607 insertions(+), 36 deletions(-) create mode 100644 src/codex/account-namespaces.ts diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 9a781a4f1..92a1c12ed 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,20 @@ gpt-5.6-sol # openai (Pool or Direct option) openai-apikey/gpt-5.6-sol # API key ``` +For intentional per-thread account selection, configure `codexAccountNamespaces` and run +`ocx sync`. The picker then also shows entries such as: + +```text +personal/gpt-5.6-sol # exact main/Desktop account +work/gpt-5.6-sol # exact added work account +``` + +An account-qualified model never switches accounts. 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..fb03bed75 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 `work/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/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index c690d848f..b3d7024bf 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -52,6 +52,7 @@ 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 picker namespace to exact Codex account id map. Use `"main"` for the current Codex Desktop/main login. Account-qualified native models fail closed and never pool-fail over. Namespace keys cannot collide with provider ids. | | `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 +78,35 @@ 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": { + "personal": "main", + "work": "work-account-id" + } +} +``` + +The added account id is the `ID` shown by `ocx account list openai`; the namespace is a user-owned +label. After `ocx sync`, Codex exposes `personal/gpt-*` and `work/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 prefixes without adding the model separately to this map. + +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 binding follows the model selector. 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/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index c7854a70d..f01b3f6e1 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -15,6 +15,12 @@ export function purgeCodexAccountRuntimeState(accountId: string): void { export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void { removeCodexAccountCredential(accountId); runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []).filter(account => account.id !== accountId); + if (runtimeConfig.codexAccountNamespaces) { + runtimeConfig.codexAccountNamespaces = Object.fromEntries( + Object.entries(runtimeConfig.codexAccountNamespaces) + .filter(([, boundAccountId]) => boundAccountId !== accountId), + ); + } if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts new file mode 100644 index 000000000..96155fa89 --- /dev/null +++ b/src/codex/account-namespaces.ts @@ -0,0 +1,26 @@ +import type { OcxConfig } from "../types"; +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."; + +export function normalizeCodexAccountNamespaceTarget(accountId: string): string { + return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || accountId === MAIN_CODEX_ACCOUNT_ID + ? 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 accountBoundNativeModelSlugs( + config: Pick, + nativeSlugs: Iterable, +): string[] { + const natives = [...nativeSlugs]; + return codexAccountNamespaceEntries(config) + .flatMap(([namespace]) => natives.map(slug => `${namespace}/${slug}`)); +} diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 44a70fbb7..b058ddeaa 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -24,6 +24,7 @@ export type CodexAuthContext = generation: number; accessToken: string; chatgptAccountId: string; + fixedAccount?: boolean; } | { // Main Codex account participating in rotation: token injected from ~/.codex/auth.json @@ -32,6 +33,7 @@ export type CodexAuthContext = accountId: string; accessToken: string; chatgptAccountId: string; + fixedAccount?: boolean; }; export type OcxRuntimeProviderConfig = OcxProviderConfig & { @@ -95,6 +97,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( @@ -107,8 +111,13 @@ export async function resolveCodexAuthContext( if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); return { kind: "main", accountId: null }; } + if (options.accountId && options.excludeAccountId) { + throw new Error("Codex auth context cannot select and exclude an account simultaneously"); + } 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 @@ -119,12 +128,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(() => {}); @@ -136,7 +148,13 @@ export async function resolveCodexAuthContext( // Main account in rotation: inject the read-only auth.json token and fail closed if it vanished. const token = getMainAccountToken(); if (!token) throw new CodexPoolAuthenticationError(); - return { kind: "main-pool", accountId, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId }; + return { + kind: "main-pool", + accountId, + accessToken: token.accessToken, + chatgptAccountId: token.chatgptAccountId, + ...(options.accountId ? { fixedAccount: true } : {}), + }; } try { @@ -147,6 +165,7 @@ export async function resolveCodexAuthContext( generation: token.generation, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId, + ...(options.accountId ? { fixedAccount: true } : {}), }; } catch (cause) { if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) { diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 8587c286a..dcdd0d97f 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 { CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION } from "../account-namespaces"; import { filterSupportedNativeSlugs } from "./parsing"; @@ -134,8 +135,10 @@ export function nativeModelRows(config: Pick): Arra export function applyNativeVisibility(entries: RawEntry[], disabledNative: Set): 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 accountBound = entry.description === CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION && slug.includes("/"); + const nativeSlug = accountBound ? slug.slice(slug.indexOf("/") + 1) : slug; + if (!nativeSlug || (!accountBound && slug.includes("/")) || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue; + entry.visibility = disabledNative.has(nativeSlug) ? "hide" : "list"; } return entries; } diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 052c057ee..fb5cd0bc6 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.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 { CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, codexAccountNamespaceEntries } from "../account-namespaces"; import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogModelSlug, ensureCatalogBackup, ensureStrictCatalogFields, findNativeTemplate, isRoutedModelCompatibilityExcluded, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; @@ -233,6 +234,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 +242,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) @@ -248,6 +251,19 @@ export function buildCatalogEntries( const e = deriveEntry(template, slug, "OpenAI native model (Codex OAuth passthrough).", 9); if (rank.has(slug)) e.priority = rank.get(slug)!; out.push(e); + nativeEntries.push(e); + } + for (const namespace of Object.keys(accountNamespaces)) { + 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 = slug; + e.description = CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION; + e.priority = rank.get(slug) + ?? (100 + (typeof native.priority === "number" ? native.priority : 9)); + out.push(e); + } } for (const m of goModels) { if (collisionSkipped.has(m)) continue; @@ -392,6 +408,7 @@ export function mergeCatalogEntriesForSync( } else { const preservedForeignRouted = catalogModels.filter(m => { if (typeof m.slug !== "string" || !m.slug.includes("/")) return false; + if (m.description === CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION) return false; const provider = m.slug.slice(0, m.slug.indexOf("/")); return !gatheredProviderNames.has(provider) && !freshSlugs.has(m.slug); }); @@ -474,7 +491,16 @@ 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 goEntries = buildCatalogEntries( + template ? JSON.parse(JSON.stringify(template)) : null, + nativeOpenAiSlugs(), + orderedGoModels, + featured, + websocketsEnabled(config), + multiAgentMode, + exactComboSlugs, + config.codexAccountNamespaces, + ).filter(entry => typeof entry.slug === "string" && entry.slug.includes("/")); // 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,6 +511,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num .filter(([, prov]) => prov.disabled !== true) .map(([name]) => name), ); + for (const [namespace] of codexAccountNamespaceEntries(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. diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 272f551b5..05a4b49fb 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -66,6 +66,8 @@ export type CodexUpstreamOutcomeMeta = { now?: number; /** When set, clears affinity for this thread immediately on transient failure. */ threadId?: string | null; + /** Fixed account-qualified models record health without mutating pool selection or affinity. */ + fixedAccount?: boolean; }; function hasConfiguredPoolAccount(config: OcxConfig, accountId: string): boolean { @@ -487,7 +489,7 @@ export function recordCodexUpstreamOutcome( lastFailureAt: now, }); markAccountNeedsReauth(accountId); - clearThreadAccountMapForAccount(accountId); + if (!meta.fixedAccount) clearThreadAccountMapForAccount(accountId); return; } @@ -498,8 +500,8 @@ export function recordCodexUpstreamOutcome( lastFailureAt: now, cooldownUntil: computeQuotaCooldownUntil(meta), }); - 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); } @@ -535,16 +537,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 6368ce64a..9570b5664 100644 --- a/src/config.ts +++ b/src/config.ts @@ -448,6 +448,40 @@ 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 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({ @@ -585,6 +619,20 @@ 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") { + const aliasNamespace = alias.slice(0, alias.indexOf("/")); + if (alias.includes("/") && accountNamespaces && typeof accountNamespaces === "object" + && !Array.isArray(accountNamespaces) && Object.hasOwn(accountNamespaces, aliasNamespace)) { + 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 48e4bc830..dff5f5b84 100644 --- a/src/router.ts +++ b/src/router.ts @@ -6,12 +6,17 @@ import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registr import { 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 +240,28 @@ 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) 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, + }; + } + } const preservePhysicalComboProvider = hasOwnProvider(config.providers, COMBO_NAMESPACE) && Object.keys(config.combos ?? {}).length === 0; @@ -253,7 +280,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/index.ts b/src/server/index.ts index 6c301827f..b6f7faed0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -347,6 +347,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 +390,14 @@ 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)); + const entries = buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2", exactComboCatalogSlugs(config), config.codexAccountNamespaces); return jsonResponse({ models: applyNativeVisibility(entries, disabledNativeSlugs(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/responses/compact.ts b/src/server/responses/compact.ts index 26a94f28f..a1f37aa85 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -183,7 +183,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) { @@ -212,7 +214,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) { @@ -255,6 +259,7 @@ export async function handleResponsesCompact( recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { ...meta, threadId: compactThreadId, + fixedAccount: authCtx.fixedAccount, }); }; let upstream: Response; @@ -338,5 +343,3 @@ export async function handleResponsesCompact( const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary); return new Response(JSON.stringify({ output }), { headers: { "Content-Type": "application/json" } }); } - - diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 019408182..a8df5dfae 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -120,7 +120,10 @@ export function sidecarOutcomeRecorder( threadId?: string | null, ): ((outcome: CodexUpstreamOutcome) => void) | undefined { return authCtx.kind === "pool" || authCtx.kind === "main-pool" - ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId }) + ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { + threadId, + fixedAccount: authCtx.fixedAccount, + }) : undefined; } @@ -207,7 +210,10 @@ 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 }); + recordCodexUpstreamOutcome(config, authCtx.accountId, 200, { + threadId, + fixedAccount: authCtx.fixedAccount, + }); return; } // status === "completed" or "failed": use the semantic HTTP status derived @@ -222,7 +228,10 @@ export function codexForwardTerminalOutcomeRecorder( const outcome = status === "completed" ? 200 : (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502); - recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId }); + recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { + threadId, + fixedAccount: authCtx.fixedAccount, + }); }; } @@ -822,7 +831,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); @@ -836,7 +847,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"); } @@ -855,7 +868,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); @@ -1026,6 +1041,7 @@ export async function handleResponses( if (usesCodexForwardPoolAuth(authCtx, route.provider)) { recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId: req.headers.get("x-codex-parent-thread-id"), + fixedAccount: authCtx.fixedAccount, }); } const msg = outcome === "timeout" @@ -1054,6 +1070,7 @@ export async function handleResponses( if ( usesCodexForwardPoolAuth(authCtx, route.provider) + && route.codexAccountId === undefined && await shouldRetryCodexPoolAccountModel400( upstreamResponse, route.modelId, @@ -1165,6 +1182,7 @@ export async function handleResponses( upstreamResponse.headers.get("x-codex-tertiary-reset-at"), ].filter(Boolean), threadId: req.headers.get("x-codex-parent-thread-id"), + fixedAccount: authCtx.fixedAccount, }); } } diff --git a/src/types.ts b/src/types.ts index c5937600f..17fe1a29b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -577,6 +577,12 @@ 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 + * `work/gpt-5.6-sol` fails closed when that account is unavailable and never pool-fails over. + */ + codexAccountNamespaces?: Record; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index c38d3b941..af7d05aa9 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,8 +18,17 @@ 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. + ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option +personal/gpt-5.6-sol # exact main/Desktop Codex account +work/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 +60,14 @@ 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. Account ids never appear in the catalog or request logs; + only the user-owned namespace is displayed. +- 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 diff --git a/tests/claude-models-discovery.test.ts b/tests/claude-models-discovery.test.ts index 159887314..9a6cbf000 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -155,3 +155,37 @@ 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", + }, + }, + codexAccountNamespaces: { personal: "main", work: "opaque-work-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("personal/gpt-"))).toBe(true); + expect(plain.data.some(model => model.id.startsWith("work/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("personal/gpt-"))).toBe(true); + expect(bound.some(model => model.slug?.startsWith("work/gpt-"))).toBe(true); + expect(JSON.stringify(codex)).not.toContain("opaque-work-account-id"); + } finally { + server.stop(true); + } +}); diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 5eda106fb..d32aa6bdd 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -881,6 +881,7 @@ describe("codex-auth API", () => { const config = makeConfig({ activeCodexAccountId: "pool-delete", codexAccounts: [{ id: "pool-delete", email: "pool-delete@example.test", isMain: false }], + codexAccountNamespaces: { work: "pool-delete", personal: "main" }, }); saveCodexAccountCredential("pool-delete", { accessToken: "access-delete", @@ -921,6 +922,7 @@ describe("codex-auth API", () => { expect(resp!.status).toBe(200); expect(config.codexAccounts).toEqual([]); expect(config.activeCodexAccountId).toBeUndefined(); + expect(config.codexAccountNamespaces).toEqual({ personal: "main" }); expect(getCodexAccountCredential("pool-delete")).toBeNull(); expect(getAccountQuota("pool-delete")).toBeNull(); expect(isAccountNeedsReauth("pool-delete")).toBe(false); diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index 3b4d42b27..8370948c4 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 { @@ -163,6 +163,78 @@ 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("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-catalog.test.ts b/tests/codex-catalog.test.ts index 5d6e4b374..926b6d990 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1003,6 +1003,48 @@ 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(), + { personal: "main", work: "opaque-account-id" }, + ); + const native = entries.find(entry => entry.slug === "gpt-5.5"); + const personal = entries.find(entry => entry.slug === "personal/gpt-5.5"); + const work = entries.find(entry => entry.slug === "work/gpt-5.5"); + + expect(personal).toBeDefined(); + expect(work).toBeDefined(); + expect(personal?.description).toBe("OpenAI native model bound to a Codex account namespace."); + expect(personal?.model_messages).toEqual(native?.model_messages); + expect(personal?.supported_reasoning_levels).toEqual(native?.supported_reasoning_levels); + expect(personal?.input_modalities).toEqual(native?.input_modalities); + expect(personal?.service_tiers).toEqual(native?.service_tiers); + expect(personal?.priority).toBeGreaterThan(100); + 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"], + [], + ["work/gpt-5.5"], + false, + "default", + new Set(), + { personal: "main", work: "work-account-id" }, + ); + expect(entries.find(entry => entry.slug === "work/gpt-5.5")?.priority).toBe(0); + expect(entries.find(entry => entry.slug === "personal/gpt-5.5")?.priority).toBeGreaterThan(100); + expect(entries.find(entry => entry.slug === "gpt-5.5")?.priority).toBe(9); + }); + 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 c8deffbf6..6be5ab431 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -112,6 +112,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/config.test.ts b/tests/config.test.ts index ac4b31bbb..7d9179980 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -704,6 +704,59 @@ 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: { personal: "main", work: "work-account-id" }, + }); + + expect(loadConfig().codexAccountNamespaces).toEqual({ personal: "main", work: "work-account-id" }); + }); + + test("Codex account namespaces cannot collide with provider routing", () => { + writeConfig({ + port: 10100, + providers: { + work: { adapter: "openai-chat", baseUrl: "https://work.example.test/v1" }, + }, + defaultProvider: "work", + codexAccountNamespaces: { work: "work-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: "work-account-id" }, + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(loadConfig()).toEqual(getDefaultConfig()); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("combo namespaces")); + } finally { + errorSpy.mockRestore(); + } + }); + test("backs up config when defaultProvider only exists on Object prototype", () => { writeConfig({ port: 10100, diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 68a9fbf9a..bb4c5fe89 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -108,6 +108,18 @@ 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: "work/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("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/router.test.ts b/tests/router.test.ts index 34af7ebb6..853cb9239 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -67,6 +67,54 @@ 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: { personal: "main", work: "work-account-id" }, + }; + + expect(routeModel(config, "personal/gpt-5.6-sol")).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.6-sol", + codexAccountMode: "pool", + codexAccountId: "__main__", + codexAccountNamespace: "personal", + }); + expect(routeModel(config, "work/gpt-5.5")).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.5", + codexAccountMode: "pool", + codexAccountId: "work-account-id", + codexAccountNamespace: "work", + }); + expect(routeModel(config, "gpt-5.5")).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.5", + codexAccountMode: "direct", + }); + }); + + 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: { work: "work-account-id" }, + }; + expect(() => routeModel(config, "work/claude-opus-4-6")).toThrow("only supports native OpenAI"); + }); + 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..3e0da24b3 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -123,14 +123,20 @@ 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 }) => 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"; + } = {}, ): Promise { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); @@ -153,11 +159,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,7 +173,8 @@ 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 } : {}), } as OcxConfig; saveConfig(config); @@ -192,12 +201,12 @@ async function startPoolRetryHarness( dispatches, server, upstream, - request: ({ stream = false, signal } = {}) => originalGlobalFetch( + request: ({ stream = false, signal, model = POOL_RETRY_MODEL } = {}) => 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 }), signal, }, ), @@ -1678,6 +1687,41 @@ describe("server local API auth", () => { } }); + test("account-qualified model preserves the selected account on an allow-listed 400", async () => { + const body = unsupportedModelBody(); + const harness = await startPoolRetryHarness( + () => rejectionResponse(body), + { accountNamespaces: { work: "pool-a" }, activeAccountId: "pool-b", accountMode: "direct" }, + ); + try { + await expectOriginal400(await harness.request({ model: `work/${POOL_RETRY_MODEL}` }), body); + expect(harness.dispatches).toEqual(["acct-pool-a"]); + expect(harness.config.activeCodexAccountId).toBe("pool-b"); + expect(getCodexUpstreamHealth("pool-b")).toBeNull(); + } finally { + await stopPoolRetryHarness(harness); + } + }); + + test("account-qualified compact request uses the same exact account", async () => { + const harness = await startPoolRetryHarness( + () => Response.json({ output: [] }), + { accountNamespaces: { work: "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: `work/${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("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 }); From 4a0558ce8842ba13b7e8542840a0c4bd50ae5a16 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Fri, 24 Jul 2026 20:19:54 -0400 Subject: [PATCH 02/15] Harden account namespace integration --- src/codex/account-namespace-match.ts | 19 +++++ src/codex/account-namespaces.ts | 11 +++ src/codex/catalog/metadata.ts | 8 +- src/codex/catalog/parsing.ts | 4 +- src/codex/catalog/sync.ts | 30 ++++++-- src/codex/routing.ts | 2 +- src/config.ts | 5 +- src/server/management/combo-routes.ts | 4 + src/server/management/provider-routes.ts | 4 + src/server/responses/core.ts | 6 +- tests/codex-v2-gate.test.ts | 17 +++++ tests/combo-management-api.test.ts | 69 +++++++++++++++++ tests/management-provider-validation.test.ts | 36 +++++++++ tests/native-model-toggle.test.ts | 20 +++++ tests/server-auth.test.ts | 78 +++++++++++++++++++- 15 files changed, 294 insertions(+), 19 deletions(-) create mode 100644 src/codex/account-namespace-match.ts 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 index 96155fa89..204dcb654 100644 --- a/src/codex/account-namespaces.ts +++ b/src/codex/account-namespaces.ts @@ -5,6 +5,17 @@ import { MAIN_CODEX_ACCOUNT_ID } from "./main-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."; +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 normalizeCodexAccountNamespaceTarget(accountId: string): string { return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || accountId === MAIN_CODEX_ACCOUNT_ID ? MAIN_CODEX_ACCOUNT_ID diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index dcdd0d97f..d6cceb41e 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -29,7 +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 { CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION } from "../account-namespaces"; +import { accountBoundNativeCatalogSlug } from "../account-namespaces"; import { filterSupportedNativeSlugs } from "./parsing"; @@ -135,9 +135,9 @@ export function nativeModelRows(config: Pick): Arra export function applyNativeVisibility(entries: RawEntry[], disabledNative: Set): RawEntry[] { for (const entry of entries) { const slug = typeof entry.slug === "string" ? entry.slug : ""; - const accountBound = entry.description === CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION && slug.includes("/"); - const nativeSlug = accountBound ? slug.slice(slug.indexOf("/") + 1) : slug; - if (!nativeSlug || (!accountBound && slug.includes("/")) || !SUPPORTED_NATIVE_OPENAI_SLUGS.has(nativeSlug)) continue; + 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) ? "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 fb5cd0bc6..72cfcdf5f 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -29,7 +29,11 @@ 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 { CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, codexAccountNamespaceEntries } from "../account-namespaces"; +import { + accountBoundNativeCatalogSlug, + CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, + codexAccountNamespaceEntries, +} from "../account-namespaces"; import { activeCodexModelsCachePath, applyJawcodeCatalogMetadata, applyMultiAgentMode, applyNativeOpenAiContextOverride, catalogModelSlug, ensureCatalogBackup, ensureStrictCatalogFields, findNativeTemplate, isRoutedModelCompatibilityExcluded, normalizeRoutedCatalogEntry, normalizeServiceTiers, readCatalog, readCatalogBackup, readCodexCatalogPath, readNativeBaseline } from "./parsing"; @@ -404,11 +408,15 @@ export function mergeCatalogEntriesForSync( const preservingExistingRouted = routedEntries.length === 0 && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/")); if (preservingExistingRouted) { - finalRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" && (m.slug as string).includes("/")); + finalRoutedEntries = catalogModels.filter(m => + typeof m.slug === "string" + && (m.slug as string).includes("/") + && accountBoundNativeCatalogSlug(m) === undefined + ); } else { const preservedForeignRouted = catalogModels.filter(m => { if (typeof m.slug !== "string" || !m.slug.includes("/")) return false; - if (m.description === CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION) 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); }); @@ -491,16 +499,26 @@ 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( + const routedEntries = buildCatalogEntries( template ? JSON.parse(JSON.stringify(template)) : null, - nativeOpenAiSlugs(), + [], orderedGoModels, featured, websocketsEnabled(config), multiAgentMode, exactComboSlugs, + ); + const accountBoundEntries = buildCatalogEntries( + template ? JSON.parse(JSON.stringify(template)) : null, + nativeOpenAiSlugs(), + [], + featured, + websocketsEnabled(config), + multiAgentMode, + exactComboSlugs, config.codexAccountNamespaces, - ).filter(entry => typeof entry.slug === "string" && entry.slug.includes("/")); + ).filter(entry => accountBoundNativeCatalogSlug(entry) !== undefined); + const goEntries = [...routedEntries, ...accountBoundEntries]; // 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. diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 05a4b49fb..6ef8eca9e 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -66,7 +66,7 @@ export type CodexUpstreamOutcomeMeta = { now?: number; /** When set, clears affinity for this thread immediately on transient failure. */ threadId?: string | null; - /** Fixed account-qualified models record health without mutating pool selection or affinity. */ + /** Suppress active-account and affinity changes for an account-qualified request. */ fixedAccount?: boolean; }; diff --git a/src/config.ts b/src/config.ts index 9570b5664..d13d9027f 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"; @@ -623,9 +624,7 @@ const configSchema = z.object({ ? (raw as { alias?: unknown }).alias : undefined; if (typeof alias === "string") { - const aliasNamespace = alias.slice(0, alias.indexOf("/")); - if (alias.includes("/") && accountNamespaces && typeof accountNamespaces === "object" - && !Array.isArray(accountNamespaces) && Object.hasOwn(accountNamespaces, aliasNamespace)) { + if (codexAccountNamespaceForModel(accountNamespaces, alias)) { ctx.addIssue({ code: "custom", path: ["combos", id, "alias"], 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 { 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(), + { work: "work-account-id" }, + ); + + expect(entries.find(entry => entry.slug === "work/gpt-5.6-sol")?.multi_agent_version).toBe("v2"); + expect(entries.find(entry => entry.slug === "work/gpt-5.6-terra")?.multi_agent_version).toBe("v2"); + expect(entries.find(entry => entry.slug === "work/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..2ca916ced 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: { work: "work-account-id" } }); + saveConfig(config); + + const response = await comboApi(config, "PUT", "/api/combos", { + id: "intentional", + combo: { ...VALID_COMBO, alias: "work/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({ work: "work-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: { work: "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("work/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/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index f48666318..0d261218c 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -443,6 +443,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: { work: "work-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: "work", + provider: { + adapter: "openai-chat", + baseUrl: "https://work.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.work).toBeUndefined(); + expect(loadConfig().codexAccountNamespaces).toEqual({ work: "work-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/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index bb4c5fe89..35b5a9758 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -120,6 +120,26 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(entries[0].visibility).toBe("hide"); }); + test("catalog sync removes stale account-qualified rows when routed discovery is empty", () => { + const merged = mergeCatalogEntriesForSync( + [ + nativeTemplate(), + { + ...nativeTemplate(), + slug: "work/gpt-5.5", + description: "OpenAI native model bound to a Codex account namespace.", + }, + ], + [], + new Map(), + [], + false, + ); + + expect(merged.some(entry => entry.slug === "work/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/server-auth.test.ts b/tests/server-auth.test.ts index 3e0da24b3..f2fa38b9b 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -123,7 +123,7 @@ function unsupportedModelBody(model = POOL_RETRY_MODEL): string { type PoolRetryHarness = { config: OcxConfig; dispatches: string[]; - request: (init?: { stream?: boolean; signal?: AbortSignal; model?: string }) => Promise; + request: (init?: { stream?: boolean; signal?: AbortSignal; model?: string; reasoningEffort?: string }) => Promise; server: ReturnType; upstream: ReturnType; }; @@ -136,6 +136,7 @@ async function startPoolRetryHarness( accountNamespaces?: Record; activeAccountId?: string; accountMode?: "pool" | "direct"; + websockets?: boolean; } = {}, ): Promise { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); @@ -176,6 +177,7 @@ async function startPoolRetryHarness( 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", { @@ -201,12 +203,17 @@ async function startPoolRetryHarness( dispatches, server, upstream, - request: ({ stream = false, signal, model = POOL_RETRY_MODEL } = {}) => 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, input: "hello", stream }), + body: JSON.stringify({ + model, + input: "hello", + stream, + ...(reasoningEffort ? { reasoning: { effort: reasoningEffort } } : {}), + }), signal, }, ), @@ -1722,6 +1729,71 @@ describe("server local API auth", () => { } }); + 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: { work: "pool-a" }, activeAccountId: "pool-b", accountMode: "direct" }, + ); + try { + const response = await harness.request({ model: "work/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: { work: "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: "work/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 }); From 653fd86992fca6110063f48a033b64ac570aef41 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Fri, 24 Jul 2026 22:08:19 -0400 Subject: [PATCH 03/15] Add opt-in account namespace picker replacement --- .../content/docs/guides/codex-app-models.md | 14 ++++++ .../content/docs/reference/configuration.md | 15 +++++- src/codex/account-namespaces.ts | 19 ++++++++ src/codex/catalog/metadata.ts | 10 +++- src/codex/catalog/sync.ts | 48 +++++++++++++++---- src/config.ts | 10 ++++ src/server/index.ts | 10 +++- src/types.ts | 6 +++ structure/08_openai-provider-tiers.md | 7 +++ tests/codex-catalog.test.ts | 2 + tests/config.test.ts | 20 ++++++++ tests/multi-agent-compat.test.ts | 19 ++++++++ tests/native-model-toggle.test.ts | 30 ++++++++++++ 13 files changed, 195 insertions(+), 15 deletions(-) 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 92a1c12ed..6989f2fa1 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -32,6 +32,20 @@ personal/gpt-5.6-sol # exact main/Desktop account work/gpt-5.6-sol # exact added work account ``` +Namespace rows are additive by default. To replace the bare native picker rows with readable +`Personal / 5.6 Sol` and `Work / 5.6 Sol` rows, opt in explicitly: + +```json +{ + "codexAccountNamespaces": { "personal": "main", "work": "work-account-id" }, + "codexAccountNamespacePickerMode": "replace-native" +} +``` + +This changes picker presentation only. Bare `gpt-*` ids remain valid for saved threads and config, +and continue to use the provider's Pool/Direct behavior. Omitting the setting keeps the existing +additive behavior. + An account-qualified model never switches accounts. 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 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index b3d7024bf..2ed72df50 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -53,6 +53,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `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 picker namespace to exact Codex account id map. Use `"main"` for the current Codex Desktop/main login. Account-qualified native models fail closed and never pool-fail over. Namespace keys cannot collide with provider ids. | +| `codexAccountNamespacePickerMode?` | `"additive" \| "replace-native"` | `"additive"` | Presentation for account-qualified native rows. `"additive"` keeps bare native rows in the picker. `"replace-native"` hides only those picker rows and gives the qualified replacements friendly labels; bare model ids remain routable. Requires at least one `codexAccountNamespaces` entry. | | `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. | @@ -87,7 +88,8 @@ Use `codexAccountNamespaces` when account choice must be visible and intentional "codexAccountNamespaces": { "personal": "main", "work": "work-account-id" - } + }, + "codexAccountNamespacePickerMode": "replace-native" } ``` @@ -96,12 +98,21 @@ label. After `ocx sync`, Codex exposes `personal/gpt-*` and `work/gpt-*` copies supported native model. When a later opencodex update adds support for another native model, the next catalog sync gives it the same prefixes without adding the model separately to this map. +Namespaces alone are additive and preserve the existing bare native picker rows. The optional +`replace-native` picker mode is a second, explicit opt-in: it hides the bare rows from the picker and +labels the replacements `Personal / 5.6 Sol`, `Work / 5.6 Sol`, and so on. It does not remove or +reroute bare model ids, so saved threads and configuration that use `gpt-*` continue to follow the +configured Pool/Direct behavior. Remove the picker-mode key or set it to `additive` to restore the +original presentation. + 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 binding follows the model selector. Use qualified values in `subagentModels`, +Account binding follows the model selector. In `replace-native` mode, a bare `subagentModels` entry +is projected onto its visible account-qualified 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 diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts index 204dcb654..546c1a6b5 100644 --- a/src/codex/account-namespaces.ts +++ b/src/codex/account-namespaces.ts @@ -5,6 +5,25 @@ import { MAIN_CODEX_ACCOUNT_ID } from "./main-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."; +export function codexAccountNamespaceDisplayName(namespace: string): string { + return namespace + .split(/[._-]+/) + .filter(Boolean) + .map(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, "").replaceAll("-", " "); + return `${codexAccountNamespaceDisplayName(namespace)} / ${model}`; +} + export function accountBoundNativeCatalogSlug(entry: { slug?: unknown; description?: unknown; diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index d6cceb41e..ce57c4d46 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -132,13 +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 : ""; 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) ? "hide" : "list"; + entry.visibility = disabledNative.has(nativeSlug) || (!accountBoundSlug && hideUnqualifiedNative) + ? "hide" + : "list"; } return entries; } diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 72cfcdf5f..deb8df9d3 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -30,6 +30,7 @@ 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, codexAccountNamespaceEntries, @@ -76,6 +77,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, @@ -104,10 +112,14 @@ 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[] => { + if (ordered.some(({ entry }) => configuredSubagentModelMatchesEntry(model, entry))) return []; const entry = configuredCatalogEntry(entries, model); if (!entry) return [{ configured: model, reason: "missing_catalog_entry" }]; const catalogModel = entry.slug as string; @@ -239,6 +251,7 @@ export function buildCatalogEntries( multiAgentMode: MultiAgentMode = "default", exactComboSlugs: ReadonlySet = new Set(), accountNamespaces: Readonly> = {}, + accountNamespacePickerMode: "additive" | "replace-native" = "additive", ): 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 @@ -254,18 +267,29 @@ 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 (accountNamespacePickerMode === "replace-native" && Object.keys(accountNamespaces).length > 0) { + e.visibility = "hide"; + } out.push(e); nativeEntries.push(e); } - for (const namespace of Object.keys(accountNamespaces)) { + 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 = slug; + e.display_name = accountNamespacePickerMode === "replace-native" + ? accountBoundNativeDisplayName(namespace, native) + : slug; e.description = CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION; - e.priority = rank.get(slug) - ?? (100 + (typeof native.priority === "number" ? native.priority : 9)); + const exactRank = rank.get(slug); + const inheritedRank = rank.get(String(native.slug)); + const nativePriority = typeof native.priority === "number" ? native.priority : 9; + e.priority = exactRank ?? (accountNamespacePickerMode === "replace-native" + ? (inheritedRank ?? nativePriority) + namespaceIndex / (namespaceNames.length + 1) + : 100 + nativePriority); + e.visibility = "list"; out.push(e); } } @@ -347,6 +371,7 @@ export function mergeCatalogEntriesForSync( multiAgentMode: MultiAgentMode = "default", exactComboSlugs: ReadonlySet = new Set(), hasPhysicalComboProvider = false, + accountNamespacePickerMode: "additive" | "replace-native" = "additive", ): RawEntry[] { const rank = new Map(featured.map((slug, i) => [slug, i] as const)); const native = catalogModels @@ -474,7 +499,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, + accountNamespacePickerMode === "replace-native", + ), multiAgentMode); } export async function syncCatalogModels(config: OcxConfig): Promise<{ added: number; path: string }> { @@ -517,6 +546,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num multiAgentMode, exactComboSlugs, config.codexAccountNamespaces, + config.codexAccountNamespacePickerMode, ).filter(entry => accountBoundNativeCatalogSlug(entry) !== undefined); const goEntries = [...routedEntries, ...accountBoundEntries]; // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append @@ -534,7 +564,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num // 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); + catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, config.codexAccountNamespacePickerMode); clampCatalogModelsToCodexSupport(catalog.models); atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n"); diff --git a/src/config.ts b/src/config.ts index d13d9027f..d45e6e48b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -444,12 +444,22 @@ const configSchema = z.object({ contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), codexShimAutoRestore: z.boolean().optional(), + codexAccountNamespacePickerMode: z.enum(["additive", "replace-native"]).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole // parse: a hand-edited typo must never trip the backup-and-defaults repair // 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 accountNamespaces = (config as { codexAccountNamespaces?: unknown }).codexAccountNamespaces; + if (config.codexAccountNamespacePickerMode === "replace-native" + && (!accountNamespaces || typeof accountNamespaces !== "object" || Array.isArray(accountNamespaces) + || Object.keys(accountNamespaces).length === 0)) { + ctx.addIssue({ + code: "custom", + path: ["codexAccountNamespacePickerMode"], + message: "replace-native picker mode requires at least one configured Codex account namespace", + }); + } if (accountNamespaces !== undefined) { if (!accountNamespaces || typeof accountNamespaces !== "object" || Array.isArray(accountNamespaces)) { ctx.addIssue({ diff --git a/src/server/index.ts b/src/server/index.ts index b6f7faed0..318d61fe4 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -390,8 +390,14 @@ 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), config.codexAccountNamespaces); - 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), config.codexAccountNamespaces, config.codexAccountNamespacePickerMode); + return jsonResponse({ + models: applyNativeVisibility( + entries, + disabledNativeSlugs(config), + config.codexAccountNamespacePickerMode === "replace-native", + ), + }, 200, req, config); } // OpenAI list shape: native gpt bare + routed models namespaced "/" // (pure availability list — disabled natives are omitted entirely). diff --git a/src/types.ts b/src/types.ts index 17fe1a29b..f8f7c1c54 100644 --- a/src/types.ts +++ b/src/types.ts @@ -583,6 +583,12 @@ export interface OcxConfig { * `work/gpt-5.6-sol` fails closed when that account is unavailable and never pool-fails over. */ codexAccountNamespaces?: Record; + /** + * Picker presentation for account-qualified native models. Default "additive" keeps the bare + * native rows; "replace-native" hides those rows and shows friendly namespace-qualified rows. + * Routing by a bare native slug remains supported in either mode. + */ + codexAccountNamespacePickerMode?: "additive" | "replace-native"; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index af7d05aa9..14cafd27f 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -25,6 +25,11 @@ bypass Pool/Direct selection, quota balancing, failure failover, thread affinity model account retry. Cooldown, missing credentials, and reauthentication fail closed on the bound account and do not mutate the active pool selection. +Picker presentation is independently opt-in. The default `codexAccountNamespacePickerMode` is +`additive`, which leaves bare native rows visible alongside qualified rows. `replace-native` hides +only the bare picker rows, uses friendly namespace labels, and orders qualified replacements in the +native model positions. Bare ids remain routable and keep their Pool/Direct semantics. + ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option personal/gpt-5.6-sol # exact main/Desktop Codex account @@ -64,6 +69,8 @@ v2 backup blocks migration before save. opencodex adds a native model to its supported catalog, the next sync creates its qualified copies without another per-namespace model list. Account ids never appear in the catalog or request logs; only the user-owned namespace is displayed. +- Replacement picker mode is an explicit presentation option, not a routing migration. Existing + bare model ids stay valid, while bare subagent selections project onto visible qualified 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 diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 926b6d990..82330c049 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1025,6 +1025,8 @@ describe("Codex catalog routed normalization", () => { expect(personal?.supported_reasoning_levels).toEqual(native?.supported_reasoning_levels); expect(personal?.input_modalities).toEqual(native?.input_modalities); expect(personal?.service_tiers).toEqual(native?.service_tiers); + expect(personal?.display_name).toBe("personal/gpt-5.5"); + expect(native?.visibility).toBe("list"); expect(personal?.priority).toBeGreaterThan(100); expect(JSON.stringify(entries)).not.toContain("opaque-account-id"); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 7d9179980..483f7cf24 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -716,9 +716,29 @@ describe("opencodex config defaults", () => { }, defaultProvider: "openai", codexAccountNamespaces: { personal: "main", work: "work-account-id" }, + codexAccountNamespacePickerMode: "replace-native", }); expect(loadConfig().codexAccountNamespaces).toEqual({ personal: "main", work: "work-account-id" }); + expect(loadConfig().codexAccountNamespacePickerMode).toBe("replace-native"); + }); + + test("replace-native picker mode requires configured account namespaces", () => { + writeConfig({ + port: 10100, + providers: { + openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, + }, + defaultProvider: "openai", + codexAccountNamespacePickerMode: "replace-native", + }); + const errorSpy = spyOn(console, "error").mockImplementation(() => {}); + try { + expect(loadConfig()).toEqual(getDefaultConfig()); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("requires at least one configured")); + } finally { + errorSpy.mockRestore(); + } }); test("Codex account namespaces cannot collide with provider routing", () => { diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index 5b55f6274..261ae4859 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,23 @@ describe("multiAgentGuidanceText", () => { } }); + test("bare subagent choices project onto visible account-qualified replacements", () => { + 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: "personal/gpt-5.6-sol", priority: 0, multiAgentVersion: "v2", description: boundDescription }, + { slug: "work/gpt-5.6-sol", priority: 0.33, multiAgentVersion: "v2", description: boundDescription }, + ]); + + const effective = effectiveSubagentRoster(["gpt-5.6-sol"], "v2"); + expect(effective.advertised.map(model => model.model)).toEqual([ + "personal/gpt-5.6-sol", + "work/gpt-5.6-sol", + ]); + expect(effective.excluded).toEqual([]); + }); + 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 35b5a9758..d5751055d 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { applyNativeVisibility, + buildCatalogEntries, disabledNativeSlugs, mergeCatalogEntriesForSync, NATIVE_OPENAI_MODELS, @@ -120,6 +121,35 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(entries[0].visibility).toBe("hide"); }); + test("replace-native mode hides bare rows and substitutes friendly account-qualified pairs", () => { + const entries = buildCatalogEntries( + nativeTemplate(), + ["gpt-5.5"], + [], + ["gpt-5.5"], + false, + "default", + new Set(), + { personal: "main", work: "work-account-id" }, + "replace-native", + ); + applyNativeVisibility(entries, new Set(), true); + + const bare = entries.find(entry => entry.slug === "gpt-5.5"); + const personal = entries.find(entry => entry.slug === "personal/gpt-5.5"); + const work = entries.find(entry => entry.slug === "work/gpt-5.5"); + expect(bare?.visibility).toBe("hide"); + expect(personal).toMatchObject({ + display_name: "Personal / 5.5", + visibility: "list", + priority: 0, + }); + expect(work?.display_name).toBe("Work / 5.5"); + expect(work?.visibility).toBe("list"); + expect(work?.priority).toBeGreaterThan(personal?.priority as number); + expect(work?.priority).toBeLessThan(1); + }); + test("catalog sync removes stale account-qualified rows when routed discovery is empty", () => { const merged = mergeCatalogEntriesForSync( [ From 42d89d6c3c3c8aa48b9326aa457e0768138ca6d2 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Fri, 24 Jul 2026 22:10:45 -0400 Subject: [PATCH 04/15] Polish synthesized account model labels --- src/codex/account-namespaces.ts | 7 ++++++- tests/native-model-toggle.test.ts | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts index 546c1a6b5..bde7ceee2 100644 --- a/src/codex/account-namespaces.ts +++ b/src/codex/account-namespaces.ts @@ -20,7 +20,12 @@ export function accountBoundNativeDisplayName(namespace: string, native: { const raw = typeof native.display_name === "string" ? native.display_name : String(native.slug ?? ""); - const model = raw.replace(/^gpt-/i, "").replaceAll("-", " "); + 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}`; } diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index d5751055d..576439336 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { accountBoundNativeDisplayName } from "../src/codex/account-namespaces"; import { applyNativeVisibility, buildCatalogEntries, @@ -32,6 +33,11 @@ function nativeTemplate(): Record { } describe("native GPT model toggles (bare slugs in disabledModels)", () => { + test("account replacement labels title-case synthesized slug words", () => { + expect(accountBoundNativeDisplayName("work", { slug: "gpt-5.3-codex-spark" })) + .toBe("Work / 5.3 Codex Spark"); + }); + 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"]); From f61d742d10783c9bf0904e89aa6cefcece7bec25 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Fri, 24 Jul 2026 22:11:54 -0400 Subject: [PATCH 05/15] Keep replacement picker priorities integral --- src/codex/catalog/sync.ts | 2 +- tests/native-model-toggle.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index deb8df9d3..17ae72d7b 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -287,7 +287,7 @@ export function buildCatalogEntries( const inheritedRank = rank.get(String(native.slug)); const nativePriority = typeof native.priority === "number" ? native.priority : 9; e.priority = exactRank ?? (accountNamespacePickerMode === "replace-native" - ? (inheritedRank ?? nativePriority) + namespaceIndex / (namespaceNames.length + 1) + ? (inheritedRank ?? nativePriority) * namespaceNames.length + namespaceIndex : 100 + nativePriority); e.visibility = "list"; out.push(e); diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 576439336..db255f91e 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -153,7 +153,8 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(work?.display_name).toBe("Work / 5.5"); expect(work?.visibility).toBe("list"); expect(work?.priority).toBeGreaterThan(personal?.priority as number); - expect(work?.priority).toBeLessThan(1); + expect(work?.priority).toBe(1); + expect(entries.every(entry => Number.isInteger(entry.priority))).toBe(true); }); test("catalog sync removes stale account-qualified rows when routed discovery is empty", () => { From 2432ef38f791301a71df621262be7ce855440f1c Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Fri, 24 Jul 2026 22:24:47 -0400 Subject: [PATCH 06/15] Add account model picker dashboard setting --- .../content/docs/guides/codex-app-models.md | 3 +- .../content/docs/reference/configuration.md | 4 +- gui/src/i18n/de.ts | 7 ++ gui/src/i18n/en.ts | 7 ++ gui/src/i18n/ja.ts | 7 ++ gui/src/i18n/ko.ts | 7 ++ gui/src/i18n/ru.ts | 7 ++ gui/src/i18n/zh.ts | 7 ++ gui/src/pages/CodexAuth.tsx | 79 ++++++++++++++++++- src/server/auth-cors.ts | 2 + src/server/management/config-routes.ts | 31 +++++++- tests/server-auth.test.ts | 7 ++ tests/settings-stream-mode.test.ts | 33 +++++++- 13 files changed, 192 insertions(+), 9 deletions(-) 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 6989f2fa1..c59c33342 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -44,7 +44,8 @@ Namespace rows are additive by default. To replace the bare native picker rows w This changes picker presentation only. Bare `gpt-*` ids remain valid for saved threads and config, and continue to use the provider's Pool/Direct behavior. Omitting the setting keeps the existing -additive behavior. +additive behavior. The **Codex Auth** dashboard exposes the same setting when account namespaces +exist and refreshes the catalog after a change. An account-qualified model never switches accounts. Missing credentials, cooldown, or reauthentication fail the request. Bare models retain normal Pool/Direct behavior, and future native diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 2ed72df50..b76c020d5 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -103,7 +103,9 @@ Namespaces alone are additive and preserve the existing bare native picker rows. labels the replacements `Personal / 5.6 Sol`, `Work / 5.6 Sol`, and so on. It does not remove or reroute bare model ids, so saved threads and configuration that use `gpt-*` continue to follow the configured Pool/Direct behavior. Remove the picker-mode key or set it to `additive` to restore the -original presentation. +original presentation. When namespaces are configured, the same choice is available on the +dashboard's **Codex Auth** page as **Bare + accounts** or **Accounts only**; changing it refreshes +the Codex catalog automatically. 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, diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 76c24b18b..fb355bbc8 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -580,6 +580,13 @@ 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.pickerModeTitle": "OpenAI-Modelle in der Auswahl", + "codexAuth.pickerModeAdditive": "Standard + Konten", + "codexAuth.pickerModeReplace": "Nur Konten", + "codexAuth.pickerModeAdditiveDesc": "Zeigt Standard-OpenAI-Modelle neben den Kontooptionen Persönlich / Arbeit.", + "codexAuth.pickerModeReplaceDesc": "Zeigt nur kontogebundene OpenAI-Optionen. Andere Anbieter bleiben unverändert.", + "codexAuth.pickerModeSaved": "Auswahl aktualisiert. Öffne Codex erneut, falls die Modellliste veraltet ist.", + "codexAuth.pickerModeSaveFailed": "Die Einstellung konnte nicht gespeichert werden. Die vorherige Auswahl bleibt erhalten.", "codexAuth.add": "Hinzufügen", "codexAuth.refreshQuota": "Kontingente aktualisieren", "codexAuth.refreshingQuota": "Aktualisiere…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index b42f850d1..67e4c180b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -881,6 +881,13 @@ 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.pickerModeTitle": "OpenAI models in picker", + "codexAuth.pickerModeAdditive": "Bare + accounts", + "codexAuth.pickerModeReplace": "Accounts only", + "codexAuth.pickerModeAdditiveDesc": "Shows bare OpenAI models alongside the Personal / Work account choices.", + "codexAuth.pickerModeReplaceDesc": "Shows only account-qualified OpenAI choices. Other providers remain unchanged.", + "codexAuth.pickerModeSaved": "Picker updated. Reopen Codex if the current model list is stale.", + "codexAuth.pickerModeSaveFailed": "The picker setting could not be saved. The previous selection is unchanged.", "codexAuth.add": "Add", "codexAuth.refreshQuota": "Refresh quotas", "codexAuth.refreshingQuota": "Refreshing...", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index c3c062242..443a90231 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -836,6 +836,13 @@ export const ja: Record = { "codexAuth.openaiMissing": "組み込みの OpenAI プロバイダーが設定されていません。", "codexAuth.openaiDisabled": "組み込みの OpenAI プロバイダーが無効です。", "codexAuth.openProviders": "プロバイダーを開く", + "codexAuth.pickerModeTitle": "モデル選択の OpenAI 表示", + "codexAuth.pickerModeAdditive": "標準 + アカウント", + "codexAuth.pickerModeReplace": "アカウントのみ", + "codexAuth.pickerModeAdditiveDesc": "標準の OpenAI モデルと個人 / 仕事アカウントの選択肢を並べて表示します。", + "codexAuth.pickerModeReplaceDesc": "アカウント指定の OpenAI モデルだけを表示します。他のプロバイダーは変わりません。", + "codexAuth.pickerModeSaved": "モデル選択を更新しました。表示が古い場合は Codex を開き直してください。", + "codexAuth.pickerModeSaveFailed": "設定を保存できませんでした。以前の選択は変更されていません。", "codexAuth.add": "追加", "codexAuth.refreshQuota": "クォータを更新", "codexAuth.refreshingQuota": "更新中...", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 81fcb3758..f8f3be736 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -599,6 +599,13 @@ export const ko: Record = { "codexAuth.openaiMissing": "내장 OpenAI 프로바이더가 설정되지 않았습니다.", "codexAuth.openaiDisabled": "내장 OpenAI 프로바이더가 비활성화되어 있습니다.", "codexAuth.openProviders": "프로바이더 열기", + "codexAuth.pickerModeTitle": "선택기의 OpenAI 모델", + "codexAuth.pickerModeAdditive": "기본 + 계정", + "codexAuth.pickerModeReplace": "계정만", + "codexAuth.pickerModeAdditiveDesc": "기본 OpenAI 모델과 개인 / 업무 계정 선택을 함께 표시합니다.", + "codexAuth.pickerModeReplaceDesc": "계정이 지정된 OpenAI 선택만 표시합니다. 다른 공급자는 변경되지 않습니다.", + "codexAuth.pickerModeSaved": "선택기가 업데이트되었습니다. 모델 목록이 오래된 경우 Codex를 다시 여세요.", + "codexAuth.pickerModeSaveFailed": "선택기 설정을 저장하지 못했습니다. 이전 선택은 유지됩니다.", "codexAuth.add": "추가", "codexAuth.refreshQuota": "할당량 새로고침", "codexAuth.refreshingQuota": "새로고침 중...", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 4346cbb5b..7e5207ca5 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -881,6 +881,13 @@ export const ru: Record = { "codexAuth.openaiMissing": "Встроенный провайдер OpenAI не настроен.", "codexAuth.openaiDisabled": "Встроенный провайдер OpenAI отключён.", "codexAuth.openProviders": "Открыть провайдеров", + "codexAuth.pickerModeTitle": "Модели OpenAI в списке", + "codexAuth.pickerModeAdditive": "Обычные + аккаунты", + "codexAuth.pickerModeReplace": "Только аккаунты", + "codexAuth.pickerModeAdditiveDesc": "Показывает обычные модели OpenAI вместе с вариантами Личный / Рабочий.", + "codexAuth.pickerModeReplaceDesc": "Показывает только привязанные к аккаунтам модели OpenAI. Другие провайдеры не меняются.", + "codexAuth.pickerModeSaved": "Список обновлён. Если модели не изменились, откройте Codex заново.", + "codexAuth.pickerModeSaveFailed": "Не удалось сохранить настройку. Предыдущий выбор не изменён.", "codexAuth.add": "Добавить", "codexAuth.refreshQuota": "Обновить квоты", "codexAuth.refreshingQuota": "Обновление...", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index d46b3ad13..4d1fcbdb0 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -599,6 +599,13 @@ export const zh: Record = { "codexAuth.openaiMissing": "未配置内置 OpenAI 提供方。", "codexAuth.openaiDisabled": "内置 OpenAI 提供方已禁用。", "codexAuth.openProviders": "打开提供商", + "codexAuth.pickerModeTitle": "选择器中的 OpenAI 模型", + "codexAuth.pickerModeAdditive": "基础 + 账户", + "codexAuth.pickerModeReplace": "仅账户", + "codexAuth.pickerModeAdditiveDesc": "同时显示基础 OpenAI 模型和个人 / 工作账户选项。", + "codexAuth.pickerModeReplaceDesc": "仅显示绑定账户的 OpenAI 选项。其他提供商保持不变。", + "codexAuth.pickerModeSaved": "选择器已更新。如果模型列表仍旧,请重新打开 Codex。", + "codexAuth.pickerModeSaveFailed": "无法保存选择器设置。之前的选择保持不变。", "codexAuth.add": "添加", "codexAuth.refreshQuota": "刷新额度", "codexAuth.refreshingQuota": "刷新中...", diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index 4ee6d9a44..d2415351a 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useT } from "../i18n"; import CodexAccountPool from "../components/CodexAccountPool"; import { codexAccountModeState, type CodexAccountModeState } from "../codex-multi-state"; @@ -11,11 +11,23 @@ import { codexAccountModeState, type CodexAccountModeState } from "../codex-mult export default function CodexAuth({ apiBase }: { apiBase: string }) { const t = useT(); const [accountModeState, setAccountModeState] = useState(null); + const [pickerMode, setPickerMode] = useState<"additive" | "replace-native" | null>(null); + const [namespaceCount, setNamespaceCount] = useState(0); + const [pickerSaving, setPickerSaving] = useState(false); + const [pickerFeedback, setPickerFeedback] = useState<{ tone: "ok" | "err"; message: string } | null>(null); + const pickerMutationInFlightRef = useRef(false); const loadMode = useCallback(async () => { try { - const config = await fetch(`${apiBase}/api/config`).then(r => r.json()); + const config = await fetch(`${apiBase}/api/config`).then(r => r.json()) as { + codexAccountNamespaceCount?: number; + codexAccountNamespacePickerMode?: "additive" | "replace-native"; + }; setAccountModeState(codexAccountModeState(config)); + if (!pickerMutationInFlightRef.current) { + setNamespaceCount(config.codexAccountNamespaceCount ?? 0); + setPickerMode(config.codexAccountNamespacePickerMode ?? "additive"); + } } catch { /* banner degrades to no badge */ } }, [apiBase]); @@ -25,6 +37,35 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { return () => { window.clearTimeout(timeout); window.clearInterval(iv); }; }, [loadMode]); + const savePickerMode = async (next: "additive" | "replace-native") => { + if (pickerSaving || pickerMode === next) return; + const previous = pickerMode; + pickerMutationInFlightRef.current = true; + setPickerSaving(true); + setPickerMode(next); + setPickerFeedback(null); + try { + const response = await fetch(`${apiBase}/api/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ codexAccountNamespacePickerMode: next }), + }); + const result = await response.json().catch(() => ({})) as { + error?: string; + codexAccountNamespacePickerMode?: "additive" | "replace-native"; + }; + if (!response.ok) throw new Error(result.error ?? "save failed"); + setPickerMode(result.codexAccountNamespacePickerMode ?? next); + setPickerFeedback({ tone: "ok", message: t("codexAuth.pickerModeSaved") }); + } catch { + setPickerMode(previous); + setPickerFeedback({ tone: "err", message: t("codexAuth.pickerModeSaveFailed") }); + } finally { + pickerMutationInFlightRef.current = false; + setPickerSaving(false); + } + }; + const banner = (
@@ -50,6 +91,40 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { {t("codexAuth.openaiDisabled")} {t("codexAuth.openProviders")}

)} + {namespaceCount > 0 && pickerMode && ( +
+
+ {t("codexAuth.pickerModeTitle")} +
+ {(["additive", "replace-native"] as const).map(mode => ( + + ))} +
+
+

+ {t(pickerMode === "replace-native" ? "codexAuth.pickerModeReplaceDesc" : "codexAuth.pickerModeAdditiveDesc")} +

+ {pickerFeedback && ( +

+ {pickerFeedback.message} +

+ )} +
+ )}
); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 4c46d3688..ce5bcf064 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -332,6 +332,8 @@ export function safeConfigDTO(config: OcxConfig): unknown { hostname: config.hostname ?? "127.0.0.1", defaultProvider: config.defaultProvider, codexAutoStart: codexAutoStartEnabled(config), + codexAccountNamespaceCount: Object.keys(config.codexAccountNamespaces ?? {}).length, + codexAccountNamespacePickerMode: config.codexAccountNamespacePickerMode ?? "additive", websockets: config.websockets, providers, }; diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 2caf8c343..27710c184 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -187,10 +187,16 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { test("safeConfigDTO redacts provider secrets and exposes booleans", () => { const unsafe = config("127.0.0.1"); unsafe.openaiProviderTierVersion = 1; + unsafe.codexAccountNamespaces = { personal: "main", work: "private-work-account-id" }; + unsafe.codexAccountNamespacePickerMode = "replace-native"; Object.assign(unsafe.providers.openai as unknown as Record, { apiKeyPool: [{ id: "pool-id", key: "pool-secret", label: "private-pool-label" }], modelMaxInputTokens: { "gpt-test": 1000 }, @@ -323,6 +325,8 @@ describe("server local API auth", () => { }); const dto = safeConfigDTO(unsafe) as { providers: Record>; + codexAccountNamespaceCount: number; + codexAccountNamespacePickerMode: string; }; const serialized = JSON.stringify(dto); for (const forbidden of [ @@ -331,7 +335,10 @@ describe("server local API auth", () => { "virtualModels", "codexAuthContext", "selectedForwardHeaders", "sidecarOutcomeRecorder", "recorder-runtime", "_codexAccountOverride", "_codexAccountRequired", "runtime-token", "override-token", + "private-work-account-id", ]) expect(serialized).not.toContain(forbidden); + expect(dto.codexAccountNamespaceCount).toBe(2); + expect(dto.codexAccountNamespacePickerMode).toBe("replace-native"); expect(dto.providers.openai).toMatchObject({ adapter: "openai-chat", baseUrl: "https://api.example.test/v1", diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index d403f237d..47e29e8b5 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 { @@ -194,6 +194,35 @@ describe("PUT /api/settings", () => { const res = await putSettings(config, {}); expect(res!.status).toBe(400); }); + + test("account picker mode persists, refreshes the catalog, and returns to the additive default", async () => { + const config = { ...baseConfig(), codexAccountNamespaces: { personal: "main", work: "work-id" } }; + let refreshes = 0; + const refresh = async () => { refreshes += 1; }; + + const replaced = await putSettings(config, { codexAccountNamespacePickerMode: "replace-native" }, refresh); + expect(replaced!.status).toBe(200); + expect(config.codexAccountNamespacePickerMode).toBe("replace-native"); + expect(refreshes).toBe(1); + expect(await replaced!.json()).toMatchObject({ + codexAccountNamespacePickerMode: "replace-native", + codexAccountNamespaceCount: 2, + }); + + const additive = await putSettings(config, { codexAccountNamespacePickerMode: "additive" }, refresh); + expect(additive!.status).toBe(200); + expect(config.codexAccountNamespacePickerMode).toBeUndefined(); + expect(refreshes).toBe(2); + expect(loadConfig().codexAccountNamespacePickerMode).toBeUndefined(); + }); + + test("account picker mode rejects invalid values and replacement without namespaces", async () => { + const config = baseConfig(); + expect((await putSettings(config, { codexAccountNamespacePickerMode: "grouped" }))!.status).toBe(400); + const missing = await putSettings(config, { codexAccountNamespacePickerMode: "replace-native" }); + expect(missing!.status).toBe(400); + expect(await missing!.json()).toMatchObject({ error: expect.stringContaining("requires at least one") }); + }); }); describe("config.json schema resilience", () => { From fc86e777b329ee8cb0d3e69176280ac2bd9cafc2 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Fri, 24 Jul 2026 22:27:51 -0400 Subject: [PATCH 07/15] Keep account picker rows together --- docs-site/src/content/docs/reference/configuration.md | 4 +++- src/codex/catalog/sync.ts | 5 +++++ tests/native-model-toggle.test.ts | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index b76c020d5..253d35dae 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -105,7 +105,9 @@ reroute bare model ids, so saved threads and configuration that use `gpt-*` cont configured Pool/Direct behavior. Remove the picker-mode key or set it to `additive` to restore the original presentation. When namespaces are configured, the same choice is available on the dashboard's **Codex Auth** page as **Bare + accounts** or **Accounts only**; changing it refreshes -the Codex catalog automatically. +the Codex catalog automatically. Because Codex exposes a flat picker rather than section metadata, +replacement mode keeps the account-qualified native pairs together ahead of ordinary routed rows; +explicitly featured subagent models still 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, diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 17ae72d7b..2b1e6cb23 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -313,6 +313,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 (accountNamespacePickerMode === "replace-native") { + // 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 diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index db255f91e..8f9d65a76 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -131,7 +131,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { const entries = buildCatalogEntries( nativeTemplate(), ["gpt-5.5"], - [], + [{ provider: "litellm-personal", id: "qwen3.6" }], ["gpt-5.5"], false, "default", @@ -144,6 +144,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { const bare = entries.find(entry => entry.slug === "gpt-5.5"); const personal = entries.find(entry => entry.slug === "personal/gpt-5.5"); const work = entries.find(entry => entry.slug === "work/gpt-5.5"); + const routed = entries.find(entry => entry.slug === "litellm-personal/qwen3.6"); expect(bare?.visibility).toBe("hide"); expect(personal).toMatchObject({ display_name: "Personal / 5.5", @@ -154,6 +155,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(work?.visibility).toBe("list"); expect(work?.priority).toBeGreaterThan(personal?.priority as number); expect(work?.priority).toBe(1); + expect(routed?.priority).toBeGreaterThan(work?.priority as number); expect(entries.every(entry => Number.isInteger(entry.priority))).toBe(true); }); From 3e5fe6be0de7008c67ccae49f99b647c854cc73c Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Fri, 24 Jul 2026 22:28:38 -0400 Subject: [PATCH 08/15] Apply replacement ordering during catalog sync --- src/codex/catalog/sync.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 2b1e6cb23..3a2006eb1 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -541,6 +541,8 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num websocketsEnabled(config), multiAgentMode, exactComboSlugs, + {}, + config.codexAccountNamespacePickerMode, ); const accountBoundEntries = buildCatalogEntries( template ? JSON.parse(JSON.stringify(template)) : null, From c15825e9064bc4ba1fa126d6f0fab5be98c5e24d Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Fri, 24 Jul 2026 22:57:52 -0400 Subject: [PATCH 09/15] Add account-specific model feature toggle --- .../content/docs/guides/codex-app-models.md | 5 +- .../content/docs/reference/configuration.md | 11 ++- gui/src/i18n/de.ts | 7 +- gui/src/i18n/en.ts | 7 +- gui/src/i18n/ja.ts | 7 +- gui/src/i18n/ko.ts | 7 +- gui/src/i18n/ru.ts | 7 +- gui/src/i18n/zh.ts | 7 +- gui/src/pages/CodexAuth.tsx | 75 ++++++++++++++++++- src/codex/account-namespaces.ts | 32 ++++++++ src/server/auth-cors.ts | 1 + src/server/management/config-routes.ts | 28 ++++++- tests/native-model-toggle.test.ts | 16 +++- tests/server-auth.test.ts | 2 + tests/settings-stream-mode.test.ts | 34 +++++++++ 15 files changed, 229 insertions(+), 17 deletions(-) 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 c59c33342..55a67d0f4 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -24,8 +24,9 @@ gpt-5.6-sol # openai (Pool or Direct option) openai-apikey/gpt-5.6-sol # API key ``` -For intentional per-thread account selection, configure `codexAccountNamespaces` and run -`ocx sync`. The picker then also shows entries such as: +For intentional per-thread account selection, turn on **Account-specific models** under +**Codex Auth**, or configure `codexAccountNamespaces` and run `ocx sync`. The picker then also shows +entries such as: ```text personal/gpt-5.6-sol # exact main/Desktop account diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 253d35dae..23bb277fa 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -98,14 +98,19 @@ label. After `ocx sync`, Codex exposes `personal/gpt-*` and `work/gpt-*` copies supported native model. When a later opencodex update adds support for another native model, the next catalog sync gives it the same prefixes without adding the model separately to this map. +The dashboard's **Codex Auth** page exposes **Account-specific models** as the feature-level opt-in. +Turning it on creates `personal` for the main/Desktop login and uses each added account's alias (or +id) for the other prefixes; turning it off removes all account-qualified catalog rows. Account ids +stay server-side. + Namespaces alone are additive and preserve the existing bare native picker rows. The optional `replace-native` picker mode is a second, explicit opt-in: it hides the bare rows from the picker and labels the replacements `Personal / 5.6 Sol`, `Work / 5.6 Sol`, and so on. It does not remove or reroute bare model ids, so saved threads and configuration that use `gpt-*` continue to follow the configured Pool/Direct behavior. Remove the picker-mode key or set it to `additive` to restore the -original presentation. When namespaces are configured, the same choice is available on the -dashboard's **Codex Auth** page as **Bare + accounts** or **Accounts only**; changing it refreshes -the Codex catalog automatically. Because Codex exposes a flat picker rather than section metadata, +original presentation. When the feature is enabled, a secondary **Picker layout** choice offers +**Bare + accounts** or **Accounts only**; changing either setting refreshes the Codex catalog +automatically. Because Codex exposes a flat picker rather than section metadata, replacement mode keeps the account-qualified native pairs together ahead of ordinary routed rows; explicitly featured subagent models still retain their higher priority. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index fb355bbc8..8ab47a1af 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -580,7 +580,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.pickerModeTitle": "OpenAI-Modelle in der Auswahl", + "codexAuth.namespacesTitle": "Kontospezifische Modelle", + "codexAuth.namespacesOnDesc": "Persönliche und kontobezogene Modelloptionen sind verfügbar und bleiben strikt an das gewählte Konto gebunden.", + "codexAuth.namespacesOffDesc": "Fügt der Codex-Modellauswahl explizite Optionen für Persönlich und Arbeit hinzu.", + "codexAuth.namespacesSaved": "Kontospezifische Modelle aktualisiert. Öffne Codex erneut, falls die Liste veraltet ist.", + "codexAuth.namespacesSaveFailed": "Kontospezifische Modelle konnten nicht aktualisiert werden. Die vorherige Einstellung bleibt erhalten.", + "codexAuth.pickerModeTitle": "Anordnung der Auswahl", "codexAuth.pickerModeAdditive": "Standard + Konten", "codexAuth.pickerModeReplace": "Nur Konten", "codexAuth.pickerModeAdditiveDesc": "Zeigt Standard-OpenAI-Modelle neben den Kontooptionen Persönlich / Arbeit.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 67e4c180b..44a97f201 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -881,7 +881,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.pickerModeTitle": "OpenAI models in picker", + "codexAuth.namespacesTitle": "Account-specific models", + "codexAuth.namespacesOnDesc": "Personal / … and account-aliased model choices are available and stay strictly bound to that account.", + "codexAuth.namespacesOffDesc": "Add explicit Personal / … and Work / … choices to the Codex model picker.", + "codexAuth.namespacesSaved": "Account-specific models updated. Reopen Codex if the current model list is stale.", + "codexAuth.namespacesSaveFailed": "Account-specific models could not be updated. The previous setting is unchanged.", + "codexAuth.pickerModeTitle": "Picker layout", "codexAuth.pickerModeAdditive": "Bare + accounts", "codexAuth.pickerModeReplace": "Accounts only", "codexAuth.pickerModeAdditiveDesc": "Shows bare OpenAI models alongside the Personal / Work account choices.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 443a90231..21f464efd 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -836,7 +836,12 @@ export const ja: Record = { "codexAuth.openaiMissing": "組み込みの OpenAI プロバイダーが設定されていません。", "codexAuth.openaiDisabled": "組み込みの OpenAI プロバイダーが無効です。", "codexAuth.openProviders": "プロバイダーを開く", - "codexAuth.pickerModeTitle": "モデル選択の OpenAI 表示", + "codexAuth.namespacesTitle": "アカウント別モデル", + "codexAuth.namespacesOnDesc": "個人用とアカウント名付きのモデルを選択でき、選んだアカウントに厳密に固定されます。", + "codexAuth.namespacesOffDesc": "Codex のモデル選択に個人用と仕事用の明示的な選択肢を追加します。", + "codexAuth.namespacesSaved": "アカウント別モデルを更新しました。表示が古い場合は Codex を開き直してください。", + "codexAuth.namespacesSaveFailed": "アカウント別モデルを更新できませんでした。以前の設定は維持されます。", + "codexAuth.pickerModeTitle": "選択リストの配置", "codexAuth.pickerModeAdditive": "標準 + アカウント", "codexAuth.pickerModeReplace": "アカウントのみ", "codexAuth.pickerModeAdditiveDesc": "標準の OpenAI モデルと個人 / 仕事アカウントの選択肢を並べて表示します。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index f8f3be736..59e9ead79 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -599,7 +599,12 @@ export const ko: Record = { "codexAuth.openaiMissing": "내장 OpenAI 프로바이더가 설정되지 않았습니다.", "codexAuth.openaiDisabled": "내장 OpenAI 프로바이더가 비활성화되어 있습니다.", "codexAuth.openProviders": "프로바이더 열기", - "codexAuth.pickerModeTitle": "선택기의 OpenAI 모델", + "codexAuth.namespacesTitle": "계정별 모델", + "codexAuth.namespacesOnDesc": "개인 및 계정 별칭 모델 선택을 사용할 수 있으며 선택한 계정에 엄격하게 고정됩니다.", + "codexAuth.namespacesOffDesc": "Codex 모델 선택기에 개인 및 업무 계정 선택을 명시적으로 추가합니다.", + "codexAuth.namespacesSaved": "계정별 모델이 업데이트되었습니다. 목록이 오래된 경우 Codex를 다시 여세요.", + "codexAuth.namespacesSaveFailed": "계정별 모델을 업데이트하지 못했습니다. 이전 설정은 유지됩니다.", + "codexAuth.pickerModeTitle": "선택기 배치", "codexAuth.pickerModeAdditive": "기본 + 계정", "codexAuth.pickerModeReplace": "계정만", "codexAuth.pickerModeAdditiveDesc": "기본 OpenAI 모델과 개인 / 업무 계정 선택을 함께 표시합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 7e5207ca5..547d295d4 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -881,7 +881,12 @@ export const ru: Record = { "codexAuth.openaiMissing": "Встроенный провайдер OpenAI не настроен.", "codexAuth.openaiDisabled": "Встроенный провайдер OpenAI отключён.", "codexAuth.openProviders": "Открыть провайдеров", - "codexAuth.pickerModeTitle": "Модели OpenAI в списке", + "codexAuth.namespacesTitle": "Модели для конкретных аккаунтов", + "codexAuth.namespacesOnDesc": "Личные и именованные варианты моделей доступны и строго привязаны к выбранному аккаунту.", + "codexAuth.namespacesOffDesc": "Добавляет в список моделей Codex явные варианты Личный и Рабочий.", + "codexAuth.namespacesSaved": "Модели аккаунтов обновлены. Если список устарел, откройте Codex заново.", + "codexAuth.namespacesSaveFailed": "Не удалось обновить модели аккаунтов. Предыдущая настройка сохранена.", + "codexAuth.pickerModeTitle": "Расположение списка", "codexAuth.pickerModeAdditive": "Обычные + аккаунты", "codexAuth.pickerModeReplace": "Только аккаунты", "codexAuth.pickerModeAdditiveDesc": "Показывает обычные модели OpenAI вместе с вариантами Личный / Рабочий.", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 4d1fcbdb0..e71b44bb3 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -599,7 +599,12 @@ export const zh: Record = { "codexAuth.openaiMissing": "未配置内置 OpenAI 提供方。", "codexAuth.openaiDisabled": "内置 OpenAI 提供方已禁用。", "codexAuth.openProviders": "打开提供商", - "codexAuth.pickerModeTitle": "选择器中的 OpenAI 模型", + "codexAuth.namespacesTitle": "账户专属模型", + "codexAuth.namespacesOnDesc": "个人和账户别名模型选项已启用,并严格绑定到所选账户。", + "codexAuth.namespacesOffDesc": "在 Codex 模型选择器中添加明确的个人和工作账户选项。", + "codexAuth.namespacesSaved": "账户专属模型已更新。如果列表仍旧,请重新打开 Codex。", + "codexAuth.namespacesSaveFailed": "无法更新账户专属模型。之前的设置保持不变。", + "codexAuth.pickerModeTitle": "选择器布局", "codexAuth.pickerModeAdditive": "基础 + 账户", "codexAuth.pickerModeReplace": "仅账户", "codexAuth.pickerModeAdditiveDesc": "同时显示基础 OpenAI 模型和个人 / 工作账户选项。", diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index d2415351a..1ee5e9ac2 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -11,8 +11,11 @@ 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 [pickerMode, setPickerMode] = useState<"additive" | "replace-native" | null>(null); const [namespaceCount, setNamespaceCount] = useState(0); + const [namespaceSaving, setNamespaceSaving] = useState(false); + const [namespaceFeedback, setNamespaceFeedback] = useState<{ tone: "ok" | "err"; message: string } | null>(null); const [pickerSaving, setPickerSaving] = useState(false); const [pickerFeedback, setPickerFeedback] = useState<{ tone: "ok" | "err"; message: string } | null>(null); const pickerMutationInFlightRef = useRef(false); @@ -20,11 +23,13 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { const loadMode = useCallback(async () => { try { const config = await fetch(`${apiBase}/api/config`).then(r => r.json()) as { + codexAccountNamespacesEnabled?: boolean; codexAccountNamespaceCount?: number; codexAccountNamespacePickerMode?: "additive" | "replace-native"; }; setAccountModeState(codexAccountModeState(config)); if (!pickerMutationInFlightRef.current) { + setNamespacesEnabled(config.codexAccountNamespacesEnabled === true); setNamespaceCount(config.codexAccountNamespaceCount ?? 0); setPickerMode(config.codexAccountNamespacePickerMode ?? "additive"); } @@ -37,6 +42,43 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { return () => { window.clearTimeout(timeout); window.clearInterval(iv); }; }, [loadMode]); + const saveNamespacesEnabled = async (next: boolean) => { + if (namespaceSaving || namespacesEnabled === null || namespacesEnabled === next) return; + const previousEnabled = namespacesEnabled; + const previousCount = namespaceCount; + const previousPickerMode = pickerMode; + pickerMutationInFlightRef.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({ codexAccountNamespacesEnabled: next }), + }); + const result = await response.json().catch(() => ({})) as { + error?: string; + codexAccountNamespacesEnabled?: boolean; + codexAccountNamespaceCount?: number; + codexAccountNamespacePickerMode?: "additive" | "replace-native"; + }; + if (!response.ok) throw new Error(result.error ?? "save failed"); + setNamespacesEnabled(result.codexAccountNamespacesEnabled === true); + setNamespaceCount(result.codexAccountNamespaceCount ?? 0); + setPickerMode(result.codexAccountNamespacePickerMode ?? "additive"); + setNamespaceFeedback({ tone: "ok", message: t("codexAuth.namespacesSaved") }); + } catch { + setNamespacesEnabled(previousEnabled); + setNamespaceCount(previousCount); + setPickerMode(previousPickerMode); + setNamespaceFeedback({ tone: "err", message: t("codexAuth.namespacesSaveFailed") }); + } finally { + pickerMutationInFlightRef.current = false; + setNamespaceSaving(false); + } + }; + const savePickerMode = async (next: "additive" | "replace-native") => { if (pickerSaving || pickerMode === next) return; const previous = pickerMode; @@ -91,7 +133,38 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { {t("codexAuth.openaiDisabled")} {t("codexAuth.openProviders")}

)} - {namespaceCount > 0 && pickerMode && ( + {namespacesEnabled !== null && ( +
+
+
+ {t("codexAuth.namespacesTitle")} +

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

+
+ +
+ {namespaceFeedback && ( +

+ {namespaceFeedback.message} +

+ )} +
+ )} + {namespacesEnabled && namespaceCount > 0 && pickerMode && (
{t("codexAuth.pickerModeTitle")} diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts index bde7ceee2..c72af640f 100644 --- a/src/codex/account-namespaces.ts +++ b/src/codex/account-namespaces.ts @@ -5,6 +5,38 @@ import { MAIN_CODEX_ACCOUNT_ID } from "./main-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"]); + +function generatedNamespace(label: string): string { + const normalized = label.trim().toLowerCase() + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[._-]+|[._-]+$/g, ""); + return normalized && !RESERVED_NAMESPACE_KEYS.has(normalized) ? normalized : "account"; +} + +/** Build the initial UI-managed namespace map without exposing account ids to the browser. */ +export function defaultCodexAccountNamespaces( + config: Pick, +): Record { + const namespaces: Record = {}; + const used = new Set([...Object.keys(config.providers), ...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("personal")] = MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET; + for (const account of config.codexAccounts ?? []) { + if (account.isMain) continue; + namespaces[claim(account.alias || account.id)] = account.id; + } + return namespaces; +} + export function codexAccountNamespaceDisplayName(namespace: string): string { return namespace .split(/[._-]+/) diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index ce5bcf064..1742bf10e 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -332,6 +332,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { hostname: config.hostname ?? "127.0.0.1", defaultProvider: config.defaultProvider, codexAutoStart: codexAutoStartEnabled(config), + codexAccountNamespacesEnabled: Object.keys(config.codexAccountNamespaces ?? {}).length > 0, codexAccountNamespaceCount: Object.keys(config.codexAccountNamespaces ?? {}).length, codexAccountNamespacePickerMode: config.codexAccountNamespacePickerMode ?? "additive", websockets: config.websockets, diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 27710c184..c82af3b84 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -34,6 +34,7 @@ import { clearThreadAccountMap } from "../../codex/routing"; 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"; +import { defaultCodexAccountNamespaces } from "../../codex/account-namespaces"; import { scanStorage } from "../../storage/scanner"; import { readUsageEntries } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; @@ -190,17 +191,23 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise 0); + if (body.codexAccountNamespacePickerMode === "replace-native" && !namespacesWillBeEnabled) { return jsonResponse({ error: "replace-native picker mode requires at least one configured Codex account namespace" }, 400); } if (typeof body.codexAutoStart === "boolean") { @@ -223,20 +232,31 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise 0; + if (body.codexAccountNamespacesEnabled === true && !namespacesWereEnabled) { + config.codexAccountNamespaces = defaultCodexAccountNamespaces(config); + } else if (body.codexAccountNamespacesEnabled === false) { + delete config.codexAccountNamespaces; + delete config.codexAccountNamespacePickerMode; + } + const namespacesAreEnabled = Object.keys(config.codexAccountNamespaces ?? {}).length > 0; const pickerModeChanged = body.codexAccountNamespacePickerMode !== undefined && body.codexAccountNamespacePickerMode !== (config.codexAccountNamespacePickerMode ?? "additive"); - if (body.codexAccountNamespacePickerMode === "replace-native") { + if (body.codexAccountNamespacePickerMode === "replace-native" && namespacesAreEnabled) { config.codexAccountNamespacePickerMode = "replace-native"; } else if (body.codexAccountNamespacePickerMode === "additive") { delete config.codexAccountNamespacePickerMode; } saveConfig(config); - if (pickerModeChanged) await refreshCodexCatalogBestEffort(); + if (pickerModeChanged || namespacesWereEnabled !== namespacesAreEnabled) { + await refreshCodexCatalogBestEffort(); + } invalidateStartupHealthCache(); return jsonResponse({ ok: true, codexAutoStart: codexAutoStartEnabled(config), streamMode: config.streamMode ?? "auto", + codexAccountNamespacesEnabled: namespacesAreEnabled, codexAccountNamespacePickerMode: config.codexAccountNamespacePickerMode ?? "additive", codexAccountNamespaceCount: Object.keys(config.codexAccountNamespaces ?? {}).length, startupHealth: await getCachedStartupHealth(config), diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 8f9d65a76..7baf0e193 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { accountBoundNativeDisplayName } from "../src/codex/account-namespaces"; +import { accountBoundNativeDisplayName, defaultCodexAccountNamespaces } from "../src/codex/account-namespaces"; import { applyNativeVisibility, buildCatalogEntries, @@ -38,6 +38,20 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { .toBe("Work / 5.3 Codex Spark"); }); + test("UI defaults generate safe unique account namespaces without exposing email labels", () => { + const namespaces = defaultCodexAccountNamespaces({ + providers: { + work: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + }, + codexAccounts: [ + { id: "work-id", email: "private@example.test", alias: "Work", isMain: false }, + { id: "team-id", email: "other@example.test", alias: "Product Team", isMain: false }, + ], + }); + expect(namespaces).toEqual({ personal: "main", "work-2": "work-id", "product-team": "team-id" }); + expect(JSON.stringify(namespaces)).not.toContain("example.test"); + }); + 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"]); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 3c1cefdd7..82e9e8467 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -325,6 +325,7 @@ describe("server local API auth", () => { }); const dto = safeConfigDTO(unsafe) as { providers: Record>; + codexAccountNamespacesEnabled: boolean; codexAccountNamespaceCount: number; codexAccountNamespacePickerMode: string; }; @@ -338,6 +339,7 @@ describe("server local API auth", () => { "private-work-account-id", ]) expect(serialized).not.toContain(forbidden); expect(dto.codexAccountNamespaceCount).toBe(2); + expect(dto.codexAccountNamespacesEnabled).toBe(true); expect(dto.codexAccountNamespacePickerMode).toBe("replace-native"); expect(dto.providers.openai).toMatchObject({ adapter: "openai-chat", diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index 47e29e8b5..391fa7efa 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -216,8 +216,42 @@ describe("PUT /api/settings", () => { expect(loadConfig().codexAccountNamespacePickerMode).toBeUndefined(); }); + test("account-specific model toggle creates aliases server-side and removes the feature cleanly", async () => { + const config: OcxConfig = { + ...baseConfig(), + codexAccounts: [ + { id: "work-id", email: "work@example.test", alias: "Work", plan: "business", isMain: false }, + ], + }; + let refreshes = 0; + const refresh = async () => { refreshes += 1; }; + + const enabled = await putSettings(config, { codexAccountNamespacesEnabled: true }, refresh); + expect(enabled!.status).toBe(200); + expect(config.codexAccountNamespaces).toEqual({ personal: "main", work: "work-id" }); + expect(await enabled!.json()).toMatchObject({ + codexAccountNamespacesEnabled: true, + codexAccountNamespaceCount: 2, + codexAccountNamespacePickerMode: "additive", + }); + expect(refreshes).toBe(1); + + config.codexAccountNamespacePickerMode = "replace-native"; + const disabled = await putSettings(config, { codexAccountNamespacesEnabled: false }, refresh); + expect(disabled!.status).toBe(200); + expect(config.codexAccountNamespaces).toBeUndefined(); + expect(config.codexAccountNamespacePickerMode).toBeUndefined(); + expect(await disabled!.json()).toMatchObject({ + codexAccountNamespacesEnabled: false, + codexAccountNamespaceCount: 0, + codexAccountNamespacePickerMode: "additive", + }); + expect(refreshes).toBe(2); + }); + test("account picker mode rejects invalid values and replacement without namespaces", async () => { const config = baseConfig(); + expect((await putSettings(config, { codexAccountNamespacesEnabled: "yes" }))!.status).toBe(400); expect((await putSettings(config, { codexAccountNamespacePickerMode: "grouped" }))!.status).toBe(400); const missing = await putSettings(config, { codexAccountNamespacePickerMode: "replace-native" }); expect(missing!.status).toBe(400); From 374313200f8fa20c9e00fe5f918c67372a289381 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Fri, 24 Jul 2026 23:32:24 -0400 Subject: [PATCH 10/15] Simplify account picker to one opt-in --- .../content/docs/guides/codex-app-models.md | 14 +- .../content/docs/reference/configuration.md | 36 +++-- .../components/CodexAccountPickerSetting.tsx | 57 ++++++++ gui/src/i18n/de.ts | 18 +-- gui/src/i18n/en.ts | 18 +-- gui/src/i18n/ja.ts | 18 +-- gui/src/i18n/ko.ts | 18 +-- gui/src/i18n/ru.ts | 18 +-- gui/src/i18n/zh.ts | 18 +-- gui/src/pages/CodexAuth.tsx | 127 ++---------------- .../codex-account-picker-setting.test.tsx | 61 +++++++++ src/codex/account-lifecycle.ts | 6 +- src/codex/account-namespaces.ts | 20 +++ src/codex/auth-api.ts | 24 +++- src/codex/catalog/sync.ts | 24 ++-- src/config.ts | 10 -- src/server/auth-cors.ts | 2 - src/server/index.ts | 4 +- src/server/management/config-routes.ts | 27 +--- src/types.ts | 6 - structure/08_openai-provider-tiers.md | 11 +- tests/codex-catalog.test.ts | 9 +- tests/config.test.ts | 20 --- tests/native-model-toggle.test.ts | 31 ++++- tests/server-auth.test.ts | 5 - tests/settings-stream-mode.test.ts | 33 +---- 26 files changed, 286 insertions(+), 349 deletions(-) create mode 100644 gui/src/components/CodexAccountPickerSetting.tsx create mode 100644 gui/tests/codex-account-picker-setting.test.tsx 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 55a67d0f4..1be1c3b12 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -33,20 +33,18 @@ personal/gpt-5.6-sol # exact main/Desktop account work/gpt-5.6-sol # exact added work account ``` -Namespace rows are additive by default. To replace the bare native picker rows with readable -`Personal / 5.6 Sol` and `Work / 5.6 Sol` rows, opt in explicitly: +The opt-in replaces plain native picker rows with readable `Personal / 5.6 Sol` and +`Work / 5.6 Sol` rows. The equivalent config is: ```json { - "codexAccountNamespaces": { "personal": "main", "work": "work-account-id" }, - "codexAccountNamespacePickerMode": "replace-native" + "codexAccountNamespaces": { "personal": "main", "work": "work-account-id" } } ``` -This changes picker presentation only. Bare `gpt-*` ids remain valid for saved threads and config, -and continue to use the provider's Pool/Direct behavior. Omitting the setting keeps the existing -additive behavior. The **Codex Auth** dashboard exposes the same setting when account namespaces -exist and refreshes the catalog after a change. +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. The **Codex Auth** +dashboard refreshes the catalog automatically after changing the toggle. An account-qualified model never switches accounts. Missing credentials, cooldown, or reauthentication fail the request. Bare models retain normal Pool/Direct behavior, and future native diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 23bb277fa..c00df62b8 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -52,8 +52,7 @@ 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 picker namespace to exact Codex account id map. Use `"main"` for the current Codex Desktop/main login. Account-qualified native models fail closed and never pool-fail over. Namespace keys cannot collide with provider ids. | -| `codexAccountNamespacePickerMode?` | `"additive" \| "replace-native"` | `"additive"` | Presentation for account-qualified native rows. `"additive"` keeps bare native rows in the picker. `"replace-native"` hides only those picker rows and gives the qualified replacements friendly labels; bare model ids remain routable. Requires at least one `codexAccountNamespaces` entry. | +| `codexAccountNamespaces?` | `Record` | `{}` | Optional picker namespace to exact Codex account id map. Use `"main"` for the current Codex Desktop/main login. When present, account-qualified native rows replace plain GPT rows in the picker and fail closed instead of switching accounts. Plain model ids remain routable. Namespace keys cannot collide with provider ids. | | `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. | @@ -88,8 +87,7 @@ Use `codexAccountNamespaces` when account choice must be visible and intentional "codexAccountNamespaces": { "personal": "main", "work": "work-account-id" - }, - "codexAccountNamespacePickerMode": "replace-native" + } } ``` @@ -98,29 +96,25 @@ label. After `ocx sync`, Codex exposes `personal/gpt-*` and `work/gpt-*` copies supported native model. When a later opencodex update adds support for another native model, the next catalog sync gives it the same prefixes without adding the model separately to this map. -The dashboard's **Codex Auth** page exposes **Account-specific models** as the feature-level opt-in. -Turning it on creates `personal` for the main/Desktop login and uses each added account's alias (or -id) for the other prefixes; turning it off removes all account-qualified catalog rows. Account ids -stay server-side. - -Namespaces alone are additive and preserve the existing bare native picker rows. The optional -`replace-native` picker mode is a second, explicit opt-in: it hides the bare rows from the picker and -labels the replacements `Personal / 5.6 Sol`, `Work / 5.6 Sol`, and so on. It does not remove or -reroute bare model ids, so saved threads and configuration that use `gpt-*` continue to follow the -configured Pool/Direct behavior. Remove the picker-mode key or set it to `additive` to restore the -original presentation. When the feature is enabled, a secondary **Picker layout** choice offers -**Bare + accounts** or **Accounts only**; changing either setting refreshes the Codex catalog -automatically. Because Codex exposes a flat picker rather than section metadata, -replacement mode keeps the account-qualified native pairs together ahead of ordinary routed rows; -explicitly featured subagent models still retain their higher priority. +The dashboard's **Codex Auth** page exposes **Choose account in model picker** as the single opt-in. +Turning it on creates `personal` for the main/Desktop login and uses each currently added account's +alias (or id) for the other prefixes. Turning it off removes the account-qualified catalog rows. +Account ids stay server-side. Prefixes stay stable if an account alias is renamed; accounts added +later receive a new prefix automatically while the feature remains enabled. + +When enabled, plain native rows are hidden from the picker and replaced by readable entries such as +`Personal / 5.6 Sol` and `Work / 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 binding follows the model selector. In `replace-native` mode, a bare `subagentModels` entry -is projected onto its visible account-qualified rows so Codex can advertise those choices; the +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- 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/i18n/de.ts b/gui/src/i18n/de.ts index 8ab47a1af..b4abf9b35 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -580,18 +580,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": "Kontospezifische Modelle", - "codexAuth.namespacesOnDesc": "Persönliche und kontobezogene Modelloptionen sind verfügbar und bleiben strikt an das gewählte Konto gebunden.", - "codexAuth.namespacesOffDesc": "Fügt der Codex-Modellauswahl explizite Optionen für Persönlich und Arbeit hinzu.", - "codexAuth.namespacesSaved": "Kontospezifische Modelle aktualisiert. Öffne Codex erneut, falls die Liste veraltet ist.", - "codexAuth.namespacesSaveFailed": "Kontospezifische Modelle konnten nicht aktualisiert werden. Die vorherige Einstellung bleibt erhalten.", - "codexAuth.pickerModeTitle": "Anordnung der Auswahl", - "codexAuth.pickerModeAdditive": "Standard + Konten", - "codexAuth.pickerModeReplace": "Nur Konten", - "codexAuth.pickerModeAdditiveDesc": "Zeigt Standard-OpenAI-Modelle neben den Kontooptionen Persönlich / Arbeit.", - "codexAuth.pickerModeReplaceDesc": "Zeigt nur kontogebundene OpenAI-Optionen. Andere Anbieter bleiben unverändert.", - "codexAuth.pickerModeSaved": "Auswahl aktualisiert. Öffne Codex erneut, falls die Modellliste veraltet ist.", - "codexAuth.pickerModeSaveFailed": "Die Einstellung konnte nicht gespeichert werden. Die vorherige Auswahl bleibt erhalten.", + "codexAuth.namespacesTitle": "Konto in der Modellauswahl wählen", + "codexAuth.namespacesOnDesc": "Jedes GPT-Modell wird einmal pro Konto angezeigt. Persönlich oder Arbeit bindet den Thread strikt an dieses Konto.", + "codexAuth.namespacesOffDesc": "GPT-Modelle verwenden den oben gewählten Pool- oder Direktmodus.", + "codexAuth.namespacesCompatibilityNote": "Das aktive Pool-Konto bleibt unverändert. Einfache GPT-Einträge werden ausgeblendet; bestehende Threads und Einstellungen funktionieren weiter.", + "codexAuth.namespacesSaved": "Kontoauswahl aktualisiert. Öffne Codex erneut, falls die Liste veraltet ist.", + "codexAuth.namespacesSaveFailed": "Die Kontoauswahl konnte nicht aktualisiert werden. Die vorherige Einstellung bleibt erhalten.", "codexAuth.add": "Hinzufügen", "codexAuth.refreshQuota": "Kontingente aktualisieren", "codexAuth.refreshingQuota": "Aktualisiere…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 44a97f201..146f1523c 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -881,18 +881,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": "Account-specific models", - "codexAuth.namespacesOnDesc": "Personal / … and account-aliased model choices are available and stay strictly bound to that account.", - "codexAuth.namespacesOffDesc": "Add explicit Personal / … and Work / … choices to the Codex model picker.", - "codexAuth.namespacesSaved": "Account-specific models updated. Reopen Codex if the current model list is stale.", - "codexAuth.namespacesSaveFailed": "Account-specific models could not be updated. The previous setting is unchanged.", - "codexAuth.pickerModeTitle": "Picker layout", - "codexAuth.pickerModeAdditive": "Bare + accounts", - "codexAuth.pickerModeReplace": "Accounts only", - "codexAuth.pickerModeAdditiveDesc": "Shows bare OpenAI models alongside the Personal / Work account choices.", - "codexAuth.pickerModeReplaceDesc": "Shows only account-qualified OpenAI choices. Other providers remain unchanged.", - "codexAuth.pickerModeSaved": "Picker updated. Reopen Codex if the current model list is stale.", - "codexAuth.pickerModeSaveFailed": "The picker setting could not be saved. The previous selection is unchanged.", + "codexAuth.namespacesTitle": "Choose account in model picker", + "codexAuth.namespacesOnDesc": "Each GPT model is listed once per account. Selecting Personal or Work locks the thread to that account and never falls back.", + "codexAuth.namespacesOffDesc": "GPT models use the Pool or Direct account mode above.", + "codexAuth.namespacesCompatibilityNote": "This does not change the active Pool account. Plain GPT entries are hidden from the picker; existing threads and saved settings still work.", + "codexAuth.namespacesSaved": "Account choices updated. Reopen Codex if the current model list is stale.", + "codexAuth.namespacesSaveFailed": "Account choices could not be updated. The previous setting is unchanged.", "codexAuth.add": "Add", "codexAuth.refreshQuota": "Refresh quotas", "codexAuth.refreshingQuota": "Refreshing...", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 21f464efd..92cb5cc78 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -836,18 +836,12 @@ export const ja: Record = { "codexAuth.openaiMissing": "組み込みの OpenAI プロバイダーが設定されていません。", "codexAuth.openaiDisabled": "組み込みの OpenAI プロバイダーが無効です。", "codexAuth.openProviders": "プロバイダーを開く", - "codexAuth.namespacesTitle": "アカウント別モデル", - "codexAuth.namespacesOnDesc": "個人用とアカウント名付きのモデルを選択でき、選んだアカウントに厳密に固定されます。", - "codexAuth.namespacesOffDesc": "Codex のモデル選択に個人用と仕事用の明示的な選択肢を追加します。", - "codexAuth.namespacesSaved": "アカウント別モデルを更新しました。表示が古い場合は Codex を開き直してください。", - "codexAuth.namespacesSaveFailed": "アカウント別モデルを更新できませんでした。以前の設定は維持されます。", - "codexAuth.pickerModeTitle": "選択リストの配置", - "codexAuth.pickerModeAdditive": "標準 + アカウント", - "codexAuth.pickerModeReplace": "アカウントのみ", - "codexAuth.pickerModeAdditiveDesc": "標準の OpenAI モデルと個人 / 仕事アカウントの選択肢を並べて表示します。", - "codexAuth.pickerModeReplaceDesc": "アカウント指定の OpenAI モデルだけを表示します。他のプロバイダーは変わりません。", - "codexAuth.pickerModeSaved": "モデル選択を更新しました。表示が古い場合は Codex を開き直してください。", - "codexAuth.pickerModeSaveFailed": "設定を保存できませんでした。以前の選択は変更されていません。", + "codexAuth.namespacesTitle": "モデル選択でアカウントを選ぶ", + "codexAuth.namespacesOnDesc": "各 GPT モデルがアカウントごとに表示されます。個人用または仕事用を選ぶと、そのアカウントに固定されます。", + "codexAuth.namespacesOffDesc": "GPT モデルは上のプールまたは直接アカウントモードを使用します。", + "codexAuth.namespacesCompatibilityNote": "アクティブなプールアカウントは変わりません。通常の GPT 項目は非表示ですが、既存スレッドと保存済み設定は引き続き動作します。", + "codexAuth.namespacesSaved": "アカウント選択を更新しました。表示が古い場合は Codex を開き直してください。", + "codexAuth.namespacesSaveFailed": "アカウント選択を更新できませんでした。以前の設定は維持されます。", "codexAuth.add": "追加", "codexAuth.refreshQuota": "クォータを更新", "codexAuth.refreshingQuota": "更新中...", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 59e9ead79..651fe84c7 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -599,18 +599,12 @@ export const ko: Record = { "codexAuth.openaiMissing": "내장 OpenAI 프로바이더가 설정되지 않았습니다.", "codexAuth.openaiDisabled": "내장 OpenAI 프로바이더가 비활성화되어 있습니다.", "codexAuth.openProviders": "프로바이더 열기", - "codexAuth.namespacesTitle": "계정별 모델", - "codexAuth.namespacesOnDesc": "개인 및 계정 별칭 모델 선택을 사용할 수 있으며 선택한 계정에 엄격하게 고정됩니다.", - "codexAuth.namespacesOffDesc": "Codex 모델 선택기에 개인 및 업무 계정 선택을 명시적으로 추가합니다.", - "codexAuth.namespacesSaved": "계정별 모델이 업데이트되었습니다. 목록이 오래된 경우 Codex를 다시 여세요.", - "codexAuth.namespacesSaveFailed": "계정별 모델을 업데이트하지 못했습니다. 이전 설정은 유지됩니다.", - "codexAuth.pickerModeTitle": "선택기 배치", - "codexAuth.pickerModeAdditive": "기본 + 계정", - "codexAuth.pickerModeReplace": "계정만", - "codexAuth.pickerModeAdditiveDesc": "기본 OpenAI 모델과 개인 / 업무 계정 선택을 함께 표시합니다.", - "codexAuth.pickerModeReplaceDesc": "계정이 지정된 OpenAI 선택만 표시합니다. 다른 공급자는 변경되지 않습니다.", - "codexAuth.pickerModeSaved": "선택기가 업데이트되었습니다. 모델 목록이 오래된 경우 Codex를 다시 여세요.", - "codexAuth.pickerModeSaveFailed": "선택기 설정을 저장하지 못했습니다. 이전 선택은 유지됩니다.", + "codexAuth.namespacesTitle": "모델 선택기에서 계정 선택", + "codexAuth.namespacesOnDesc": "각 GPT 모델이 계정별로 표시됩니다. 개인 또는 업무를 선택하면 해당 계정에 고정되며 대체되지 않습니다.", + "codexAuth.namespacesOffDesc": "GPT 모델은 위의 풀 또는 직접 계정 모드를 사용합니다.", + "codexAuth.namespacesCompatibilityNote": "활성 풀 계정은 변경되지 않습니다. 일반 GPT 항목은 숨겨지지만 기존 스레드와 저장된 설정은 계속 작동합니다.", + "codexAuth.namespacesSaved": "계정 선택이 업데이트되었습니다. 목록이 오래된 경우 Codex를 다시 여세요.", + "codexAuth.namespacesSaveFailed": "계정 선택을 업데이트하지 못했습니다. 이전 설정은 유지됩니다.", "codexAuth.add": "추가", "codexAuth.refreshQuota": "할당량 새로고침", "codexAuth.refreshingQuota": "새로고침 중...", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 547d295d4..e388cc402 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -881,18 +881,12 @@ export const ru: Record = { "codexAuth.openaiMissing": "Встроенный провайдер OpenAI не настроен.", "codexAuth.openaiDisabled": "Встроенный провайдер OpenAI отключён.", "codexAuth.openProviders": "Открыть провайдеров", - "codexAuth.namespacesTitle": "Модели для конкретных аккаунтов", - "codexAuth.namespacesOnDesc": "Личные и именованные варианты моделей доступны и строго привязаны к выбранному аккаунту.", - "codexAuth.namespacesOffDesc": "Добавляет в список моделей Codex явные варианты Личный и Рабочий.", - "codexAuth.namespacesSaved": "Модели аккаунтов обновлены. Если список устарел, откройте Codex заново.", - "codexAuth.namespacesSaveFailed": "Не удалось обновить модели аккаунтов. Предыдущая настройка сохранена.", - "codexAuth.pickerModeTitle": "Расположение списка", - "codexAuth.pickerModeAdditive": "Обычные + аккаунты", - "codexAuth.pickerModeReplace": "Только аккаунты", - "codexAuth.pickerModeAdditiveDesc": "Показывает обычные модели OpenAI вместе с вариантами Личный / Рабочий.", - "codexAuth.pickerModeReplaceDesc": "Показывает только привязанные к аккаунтам модели OpenAI. Другие провайдеры не меняются.", - "codexAuth.pickerModeSaved": "Список обновлён. Если модели не изменились, откройте Codex заново.", - "codexAuth.pickerModeSaveFailed": "Не удалось сохранить настройку. Предыдущий выбор не изменён.", + "codexAuth.namespacesTitle": "Выбирать аккаунт в списке моделей", + "codexAuth.namespacesOnDesc": "Каждая GPT-модель показана отдельно для каждого аккаунта. Выбор Личный или Рабочий строго закрепляет поток за ним.", + "codexAuth.namespacesOffDesc": "GPT-модели используют режим Пул или Прямой выше.", + "codexAuth.namespacesCompatibilityNote": "Активный аккаунт пула не меняется. Обычные GPT-пункты скрыты, но существующие потоки и настройки продолжают работать.", + "codexAuth.namespacesSaved": "Выбор аккаунта обновлён. Если список устарел, откройте Codex заново.", + "codexAuth.namespacesSaveFailed": "Не удалось обновить выбор аккаунта. Предыдущая настройка сохранена.", "codexAuth.add": "Добавить", "codexAuth.refreshQuota": "Обновить квоты", "codexAuth.refreshingQuota": "Обновление...", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e71b44bb3..c37188e10 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -599,18 +599,12 @@ export const zh: Record = { "codexAuth.openaiMissing": "未配置内置 OpenAI 提供方。", "codexAuth.openaiDisabled": "内置 OpenAI 提供方已禁用。", "codexAuth.openProviders": "打开提供商", - "codexAuth.namespacesTitle": "账户专属模型", - "codexAuth.namespacesOnDesc": "个人和账户别名模型选项已启用,并严格绑定到所选账户。", - "codexAuth.namespacesOffDesc": "在 Codex 模型选择器中添加明确的个人和工作账户选项。", - "codexAuth.namespacesSaved": "账户专属模型已更新。如果列表仍旧,请重新打开 Codex。", - "codexAuth.namespacesSaveFailed": "无法更新账户专属模型。之前的设置保持不变。", - "codexAuth.pickerModeTitle": "选择器布局", - "codexAuth.pickerModeAdditive": "基础 + 账户", - "codexAuth.pickerModeReplace": "仅账户", - "codexAuth.pickerModeAdditiveDesc": "同时显示基础 OpenAI 模型和个人 / 工作账户选项。", - "codexAuth.pickerModeReplaceDesc": "仅显示绑定账户的 OpenAI 选项。其他提供商保持不变。", - "codexAuth.pickerModeSaved": "选择器已更新。如果模型列表仍旧,请重新打开 Codex。", - "codexAuth.pickerModeSaveFailed": "无法保存选择器设置。之前的选择保持不变。", + "codexAuth.namespacesTitle": "在模型选择器中选择账户", + "codexAuth.namespacesOnDesc": "每个 GPT 模型按账户分别显示。选择个人或工作后,线程会严格绑定该账户且不会回退。", + "codexAuth.namespacesOffDesc": "GPT 模型使用上方的账户池或直接模式。", + "codexAuth.namespacesCompatibilityNote": "不会更改当前账户池账户。普通 GPT 条目会隐藏,但现有线程和已保存设置仍然有效。", + "codexAuth.namespacesSaved": "账户选择已更新。如果列表仍旧,请重新打开 Codex。", + "codexAuth.namespacesSaveFailed": "无法更新账户选择。之前的设置保持不变。", "codexAuth.add": "添加", "codexAuth.refreshQuota": "刷新额度", "codexAuth.refreshingQuota": "刷新中...", diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index 1ee5e9ac2..43ad83c49 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -1,6 +1,7 @@ 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"; /** @@ -12,26 +13,18 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { const t = useT(); const [accountModeState, setAccountModeState] = useState(null); const [namespacesEnabled, setNamespacesEnabled] = useState(null); - const [pickerMode, setPickerMode] = useState<"additive" | "replace-native" | null>(null); - const [namespaceCount, setNamespaceCount] = useState(0); const [namespaceSaving, setNamespaceSaving] = useState(false); const [namespaceFeedback, setNamespaceFeedback] = useState<{ tone: "ok" | "err"; message: string } | null>(null); - const [pickerSaving, setPickerSaving] = useState(false); - const [pickerFeedback, setPickerFeedback] = useState<{ tone: "ok" | "err"; message: string } | null>(null); - const pickerMutationInFlightRef = useRef(false); + const namespaceMutationInFlightRef = useRef(false); const loadMode = useCallback(async () => { try { const config = await fetch(`${apiBase}/api/config`).then(r => r.json()) as { codexAccountNamespacesEnabled?: boolean; - codexAccountNamespaceCount?: number; - codexAccountNamespacePickerMode?: "additive" | "replace-native"; }; setAccountModeState(codexAccountModeState(config)); - if (!pickerMutationInFlightRef.current) { + if (!namespaceMutationInFlightRef.current) { setNamespacesEnabled(config.codexAccountNamespacesEnabled === true); - setNamespaceCount(config.codexAccountNamespaceCount ?? 0); - setPickerMode(config.codexAccountNamespacePickerMode ?? "additive"); } } catch { /* banner degrades to no badge */ } }, [apiBase]); @@ -45,9 +38,7 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { const saveNamespacesEnabled = async (next: boolean) => { if (namespaceSaving || namespacesEnabled === null || namespacesEnabled === next) return; const previousEnabled = namespacesEnabled; - const previousCount = namespaceCount; - const previousPickerMode = pickerMode; - pickerMutationInFlightRef.current = true; + namespaceMutationInFlightRef.current = true; setNamespaceSaving(true); setNamespacesEnabled(next); setNamespaceFeedback(null); @@ -60,54 +51,19 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { const result = await response.json().catch(() => ({})) as { error?: string; codexAccountNamespacesEnabled?: boolean; - codexAccountNamespaceCount?: number; - codexAccountNamespacePickerMode?: "additive" | "replace-native"; }; if (!response.ok) throw new Error(result.error ?? "save failed"); setNamespacesEnabled(result.codexAccountNamespacesEnabled === true); - setNamespaceCount(result.codexAccountNamespaceCount ?? 0); - setPickerMode(result.codexAccountNamespacePickerMode ?? "additive"); setNamespaceFeedback({ tone: "ok", message: t("codexAuth.namespacesSaved") }); } catch { setNamespacesEnabled(previousEnabled); - setNamespaceCount(previousCount); - setPickerMode(previousPickerMode); setNamespaceFeedback({ tone: "err", message: t("codexAuth.namespacesSaveFailed") }); } finally { - pickerMutationInFlightRef.current = false; + namespaceMutationInFlightRef.current = false; setNamespaceSaving(false); } }; - const savePickerMode = async (next: "additive" | "replace-native") => { - if (pickerSaving || pickerMode === next) return; - const previous = pickerMode; - pickerMutationInFlightRef.current = true; - setPickerSaving(true); - setPickerMode(next); - setPickerFeedback(null); - try { - const response = await fetch(`${apiBase}/api/settings`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codexAccountNamespacePickerMode: next }), - }); - const result = await response.json().catch(() => ({})) as { - error?: string; - codexAccountNamespacePickerMode?: "additive" | "replace-native"; - }; - if (!response.ok) throw new Error(result.error ?? "save failed"); - setPickerMode(result.codexAccountNamespacePickerMode ?? next); - setPickerFeedback({ tone: "ok", message: t("codexAuth.pickerModeSaved") }); - } catch { - setPickerMode(previous); - setPickerFeedback({ tone: "err", message: t("codexAuth.pickerModeSaveFailed") }); - } finally { - pickerMutationInFlightRef.current = false; - setPickerSaving(false); - } - }; - const banner = (
@@ -133,71 +89,14 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { {t("codexAuth.openaiDisabled")} {t("codexAuth.openProviders")}

)} - {namespacesEnabled !== null && ( -
-
-
- {t("codexAuth.namespacesTitle")} -

- {t(namespacesEnabled ? "codexAuth.namespacesOnDesc" : "codexAuth.namespacesOffDesc")} -

-
- -
- {namespaceFeedback && ( -

- {namespaceFeedback.message} -

- )} -
- )} - {namespacesEnabled && namespaceCount > 0 && pickerMode && ( -
-
- {t("codexAuth.pickerModeTitle")} -
- {(["additive", "replace-native"] as const).map(mode => ( - - ))} -
-
-

- {t(pickerMode === "replace-native" ? "codexAuth.pickerModeReplaceDesc" : "codexAuth.pickerModeAdditiveDesc")} -

- {pickerFeedback && ( -

- {pickerFeedback.message} -

- )} -
- )} + { + if (namespacesEnabled !== null) void saveNamespacesEnabled(!namespacesEnabled); + }} + />
); 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..d675d8222 --- /dev/null +++ b/gui/tests/codex-account-picker-setting.test.tsx @@ -0,0 +1,61 @@ +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("Choose account in model picker"); + expect(html).toContain("GPT models use the Pool or Direct account mode above."); + 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("locks the thread to that account and never falls back"); + expect(html).toContain("does not change the active Pool account"); + expect(html).toContain("existing threads and saved settings still work"); + 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/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index f01b3f6e1..f83dabcee 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -12,16 +12,20 @@ export function purgeCodexAccountRuntimeState(accountId: string): void { clearCodexUpstreamHealthForAccount(accountId); } -export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void { +export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): boolean { removeCodexAccountCredential(accountId); runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []).filter(account => account.id !== accountId); + let namespaceRemoved = false; if (runtimeConfig.codexAccountNamespaces) { + const previousCount = Object.keys(runtimeConfig.codexAccountNamespaces).length; runtimeConfig.codexAccountNamespaces = Object.fromEntries( Object.entries(runtimeConfig.codexAccountNamespaces) .filter(([, boundAccountId]) => boundAccountId !== accountId), ); + namespaceRemoved = Object.keys(runtimeConfig.codexAccountNamespaces).length !== previousCount; } if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); + return namespaceRemoved; } diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts index c72af640f..c5168c963 100644 --- a/src/codex/account-namespaces.ts +++ b/src/codex/account-namespaces.ts @@ -37,6 +37,26 @@ export function defaultCodexAccountNamespaces( return namespaces; } +/** Add a newly stored account to an already-enabled UI-managed picker without renaming old rows. */ +export function appendDefaultCodexAccountNamespace( + config: Pick, + account: { id: string; alias?: string; isMain: boolean }, +): boolean { + if (account.isMain || !config.codexAccountNamespaces) return false; + if (Object.values(config.codexAccountNamespaces).includes(account.id)) return false; + const used = new Set([ + ...Object.keys(config.providers), + ...Object.keys(config.codexAccountNamespaces), + ...RESERVED_NAMESPACE_KEYS, + ]); + const base = generatedNamespace(account.alias || account.id); + 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(/[._-]+/) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 079d7e7bb..d58945cf3 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -10,6 +10,7 @@ import { TokenRefreshError, } from "./account-store"; import { deleteCodexAccount } from "./account-lifecycle"; +import { appendDefaultCodexAccountNamespace } from "./account-namespaces"; import { checkAccountIdCollision, readCodexTokens } from "./auth-collision"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; @@ -419,6 +420,16 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa return [main, ...withQuota]; } +async function refreshAccountNamespaceCatalog(config: OcxConfig, changed: boolean): Promise { + if (!changed) return; + try { + const { refreshCodexModelCatalog } = await import("./refresh"); + await refreshCodexModelCatalog(config); + } catch { + // Account persistence succeeds even when no Codex catalog has been injected yet. + } +} + export async function handleCodexAuthAPI( req: Request, url: URL, @@ -468,9 +479,12 @@ 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); + accounts.push(addedAccount); runtimeConfig.codexAccounts = accounts; + const namespaceAdded = appendDefaultCodexAccountNamespace(runtimeConfig, addedAccount); saveRuntimeConfig(config, runtimeConfig); + await refreshAccountNamespaceCatalog(runtimeConfig, namespaceAdded); return jsonResponse({ ok: true }); } @@ -478,8 +492,9 @@ export async function handleCodexAuthAPI( const id = url.searchParams.get("id"); if (!id) return jsonResponse({ error: "Missing id" }, 400); const runtimeConfig = getRuntimeConfig(config); - deleteCodexAccount(runtimeConfig, id); + const namespaceRemoved = deleteCodexAccount(runtimeConfig, id); saveRuntimeConfig(config, runtimeConfig); + await refreshAccountNamespaceCatalog(runtimeConfig, namespaceRemoved); return jsonResponse({ ok: true }); } @@ -778,9 +793,12 @@ 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); + accounts.push(addedAccount); latestConfig.codexAccounts = accounts; + const namespaceAdded = appendDefaultCodexAccountNamespace(latestConfig, addedAccount); saveRuntimeConfig(config, latestConfig); + await refreshAccountNamespaceCatalog(latestConfig, namespaceAdded); } codexAuthLoginState.set(flowId, { status: "done", accountId, email, doneAt: Date.now() }); completed = true; diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 3a2006eb1..34b828a10 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -251,7 +251,6 @@ export function buildCatalogEntries( multiAgentMode: MultiAgentMode = "default", exactComboSlugs: ReadonlySet = new Set(), accountNamespaces: Readonly> = {}, - accountNamespacePickerMode: "additive" | "replace-native" = "additive", ): 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 @@ -267,7 +266,7 @@ 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 (accountNamespacePickerMode === "replace-native" && Object.keys(accountNamespaces).length > 0) { + if (Object.keys(accountNamespaces).length > 0) { e.visibility = "hide"; } out.push(e); @@ -279,16 +278,13 @@ export function buildCatalogEntries( const slug = `${namespace}/${String(native.slug)}`; const e = JSON.parse(JSON.stringify(native)) as RawEntry; e.slug = slug; - e.display_name = accountNamespacePickerMode === "replace-native" - ? accountBoundNativeDisplayName(namespace, native) - : 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 ?? (accountNamespacePickerMode === "replace-native" - ? (inheritedRank ?? nativePriority) * namespaceNames.length + namespaceIndex - : 100 + nativePriority); + e.priority = exactRank + ?? (inheritedRank ?? nativePriority) * namespaceNames.length + namespaceIndex; e.visibility = "list"; out.push(e); } @@ -313,7 +309,7 @@ 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 (accountNamespacePickerMode === "replace-native") { + 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); @@ -376,7 +372,7 @@ export function mergeCatalogEntriesForSync( multiAgentMode: MultiAgentMode = "default", exactComboSlugs: ReadonlySet = new Set(), hasPhysicalComboProvider = false, - accountNamespacePickerMode: "additive" | "replace-native" = "additive", + accountNamespacesEnabled = false, ): RawEntry[] { const rank = new Map(featured.map((slug, i) => [slug, i] as const)); const native = catalogModels @@ -507,7 +503,7 @@ export function mergeCatalogEntriesForSync( return applyMultiAgentMode(applyNativeVisibility( mergedEntries, disabledNative, - accountNamespacePickerMode === "replace-native", + accountNamespacesEnabled, ), multiAgentMode); } @@ -541,8 +537,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num websocketsEnabled(config), multiAgentMode, exactComboSlugs, - {}, - config.codexAccountNamespacePickerMode, + config.codexAccountNamespaces, ); const accountBoundEntries = buildCatalogEntries( template ? JSON.parse(JSON.stringify(template)) : null, @@ -553,7 +548,6 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num multiAgentMode, exactComboSlugs, config.codexAccountNamespaces, - config.codexAccountNamespacePickerMode, ).filter(entry => accountBoundNativeCatalogSlug(entry) !== undefined); const goEntries = [...routedEntries, ...accountBoundEntries]; // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append @@ -571,7 +565,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num // 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, config.codexAccountNamespacePickerMode); + catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, Object.keys(config.codexAccountNamespaces ?? {}).length > 0); clampCatalogModelsToCodexSupport(catalog.models); atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n"); diff --git a/src/config.ts b/src/config.ts index d45e6e48b..d13d9027f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -444,22 +444,12 @@ const configSchema = z.object({ contextCapValue: z.number().int().positive().optional(), multiAgentGuidanceEnabled: z.boolean().optional(), codexShimAutoRestore: z.boolean().optional(), - codexAccountNamespacePickerMode: z.enum(["additive", "replace-native"]).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole // parse: a hand-edited typo must never trip the backup-and-defaults repair // 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 accountNamespaces = (config as { codexAccountNamespaces?: unknown }).codexAccountNamespaces; - if (config.codexAccountNamespacePickerMode === "replace-native" - && (!accountNamespaces || typeof accountNamespaces !== "object" || Array.isArray(accountNamespaces) - || Object.keys(accountNamespaces).length === 0)) { - ctx.addIssue({ - code: "custom", - path: ["codexAccountNamespacePickerMode"], - message: "replace-native picker mode requires at least one configured Codex account namespace", - }); - } if (accountNamespaces !== undefined) { if (!accountNamespaces || typeof accountNamespaces !== "object" || Array.isArray(accountNamespaces)) { ctx.addIssue({ diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 1742bf10e..cf6e218ce 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -333,8 +333,6 @@ export function safeConfigDTO(config: OcxConfig): unknown { defaultProvider: config.defaultProvider, codexAutoStart: codexAutoStartEnabled(config), codexAccountNamespacesEnabled: Object.keys(config.codexAccountNamespaces ?? {}).length > 0, - codexAccountNamespaceCount: Object.keys(config.codexAccountNamespaces ?? {}).length, - codexAccountNamespacePickerMode: config.codexAccountNamespacePickerMode ?? "additive", websockets: config.websockets, providers, }; diff --git a/src/server/index.ts b/src/server/index.ts index 318d61fe4..08853376c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -390,12 +390,12 @@ 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), config.codexAccountNamespaces, config.codexAccountNamespacePickerMode); + const entries = buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config), maMode as "v1" | "default" | "v2", exactComboCatalogSlugs(config), config.codexAccountNamespaces); return jsonResponse({ models: applyNativeVisibility( entries, disabledNativeSlugs(config), - config.codexAccountNamespacePickerMode === "replace-native", + Object.keys(config.codexAccountNamespaces ?? {}).length > 0, ), }, 200, req, config); } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index c82af3b84..5f245965f 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -192,13 +192,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise 0); - if (body.codexAccountNamespacePickerMode === "replace-native" && !namespacesWillBeEnabled) { - return jsonResponse({ error: "replace-native picker mode requires at least one configured Codex account namespace" }, 400); - } if (typeof body.codexAutoStart === "boolean") { config.codexAutoStart = body.codexAutoStart; } @@ -237,18 +224,10 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise 0; - const pickerModeChanged = body.codexAccountNamespacePickerMode !== undefined - && body.codexAccountNamespacePickerMode !== (config.codexAccountNamespacePickerMode ?? "additive"); - if (body.codexAccountNamespacePickerMode === "replace-native" && namespacesAreEnabled) { - config.codexAccountNamespacePickerMode = "replace-native"; - } else if (body.codexAccountNamespacePickerMode === "additive") { - delete config.codexAccountNamespacePickerMode; - } saveConfig(config); - if (pickerModeChanged || namespacesWereEnabled !== namespacesAreEnabled) { + if (namespacesWereEnabled !== namespacesAreEnabled) { await refreshCodexCatalogBestEffort(); } invalidateStartupHealthCache(); @@ -257,8 +236,6 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise; - /** - * Picker presentation for account-qualified native models. Default "additive" keeps the bare - * native rows; "replace-native" hides those rows and shows friendly namespace-qualified rows. - * Routing by a bare native slug remains supported in either mode. - */ - codexAccountNamespacePickerMode?: "additive" | "replace-native"; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 14cafd27f..7a5680270 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -25,10 +25,9 @@ bypass Pool/Direct selection, quota balancing, failure failover, thread affinity model account retry. Cooldown, missing credentials, and reauthentication fail closed on the bound account and do not mutate the active pool selection. -Picker presentation is independently opt-in. The default `codexAccountNamespacePickerMode` is -`additive`, which leaves bare native rows visible alongside qualified rows. `replace-native` hides -only the bare picker rows, uses friendly namespace labels, and orders qualified replacements in the -native model positions. Bare ids remain routable and keep their Pool/Direct semantics. +The namespace map is the picker opt-in. When present, friendly account-specific rows replace plain +native rows in the picker and remain grouped ahead of ordinary routed providers. Plain ids remain +routable and keep their Pool/Direct semantics for saved threads and configuration. ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option @@ -69,8 +68,8 @@ v2 backup blocks migration before save. opencodex adds a native model to its supported catalog, the next sync creates its qualified copies without another per-namespace model list. Account ids never appear in the catalog or request logs; only the user-owned namespace is displayed. -- Replacement picker mode is an explicit presentation option, not a routing migration. Existing - bare model ids stay valid, while bare subagent selections project onto visible qualified rows. +- 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 diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 82330c049..412395fe1 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1025,9 +1025,9 @@ describe("Codex catalog routed normalization", () => { expect(personal?.supported_reasoning_levels).toEqual(native?.supported_reasoning_levels); expect(personal?.input_modalities).toEqual(native?.input_modalities); expect(personal?.service_tiers).toEqual(native?.service_tiers); - expect(personal?.display_name).toBe("personal/gpt-5.5"); - expect(native?.visibility).toBe("list"); - expect(personal?.priority).toBeGreaterThan(100); + expect(personal?.display_name).toBe("Personal / 5.5"); + expect(native?.visibility).toBe("hide"); + expect(Number.isInteger(personal?.priority)).toBe(true); expect(JSON.stringify(entries)).not.toContain("opaque-account-id"); }); @@ -1043,8 +1043,9 @@ describe("Codex catalog routed normalization", () => { { personal: "main", work: "work-account-id" }, ); expect(entries.find(entry => entry.slug === "work/gpt-5.5")?.priority).toBe(0); - expect(entries.find(entry => entry.slug === "personal/gpt-5.5")?.priority).toBeGreaterThan(100); + expect(entries.find(entry => entry.slug === "personal/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", () => { diff --git a/tests/config.test.ts b/tests/config.test.ts index 483f7cf24..7d9179980 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -716,29 +716,9 @@ describe("opencodex config defaults", () => { }, defaultProvider: "openai", codexAccountNamespaces: { personal: "main", work: "work-account-id" }, - codexAccountNamespacePickerMode: "replace-native", }); expect(loadConfig().codexAccountNamespaces).toEqual({ personal: "main", work: "work-account-id" }); - expect(loadConfig().codexAccountNamespacePickerMode).toBe("replace-native"); - }); - - test("replace-native picker mode requires configured account namespaces", () => { - writeConfig({ - port: 10100, - providers: { - openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, - }, - defaultProvider: "openai", - codexAccountNamespacePickerMode: "replace-native", - }); - const errorSpy = spyOn(console, "error").mockImplementation(() => {}); - try { - expect(loadConfig()).toEqual(getDefaultConfig()); - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("requires at least one configured")); - } finally { - errorSpy.mockRestore(); - } }); test("Codex account namespaces cannot collide with provider routing", () => { diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 7baf0e193..7fea1614f 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from "bun:test"; -import { accountBoundNativeDisplayName, defaultCodexAccountNamespaces } from "../src/codex/account-namespaces"; +import { + accountBoundNativeDisplayName, + appendDefaultCodexAccountNamespace, + defaultCodexAccountNamespaces, +} from "../src/codex/account-namespaces"; import { applyNativeVisibility, buildCatalogEntries, @@ -52,6 +56,28 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(JSON.stringify(namespaces)).not.toContain("example.test"); }); + test("new accounts append to an enabled map without renaming existing prefixes", () => { + const config = { + providers: { work: { adapter: "openai-chat" as const, baseUrl: "https://example.test/v1" } }, + codexAccountNamespaces: { personal: "main", legacy: "legacy-id" }, + }; + expect(appendDefaultCodexAccountNamespace(config, { + id: "work-id", + alias: "Work", + isMain: false, + })).toBe(true); + expect(config.codexAccountNamespaces).toEqual({ + personal: "main", + legacy: "legacy-id", + "work-2": "work-id", + }); + expect(appendDefaultCodexAccountNamespace(config, { + id: "work-id", + alias: "Renamed Work", + isMain: false, + })).toBe(false); + }); + 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"]); @@ -141,7 +167,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { expect(entries[0].visibility).toBe("hide"); }); - test("replace-native mode hides bare rows and substitutes friendly account-qualified pairs", () => { + test("account namespaces hide bare rows and substitute friendly account-qualified pairs", () => { const entries = buildCatalogEntries( nativeTemplate(), ["gpt-5.5"], @@ -151,7 +177,6 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { "default", new Set(), { personal: "main", work: "work-account-id" }, - "replace-native", ); applyNativeVisibility(entries, new Set(), true); diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 82e9e8467..54d5b9942 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -311,7 +311,6 @@ describe("server local API auth", () => { const unsafe = config("127.0.0.1"); unsafe.openaiProviderTierVersion = 1; unsafe.codexAccountNamespaces = { personal: "main", work: "private-work-account-id" }; - unsafe.codexAccountNamespacePickerMode = "replace-native"; Object.assign(unsafe.providers.openai as unknown as Record, { apiKeyPool: [{ id: "pool-id", key: "pool-secret", label: "private-pool-label" }], modelMaxInputTokens: { "gpt-test": 1000 }, @@ -326,8 +325,6 @@ describe("server local API auth", () => { const dto = safeConfigDTO(unsafe) as { providers: Record>; codexAccountNamespacesEnabled: boolean; - codexAccountNamespaceCount: number; - codexAccountNamespacePickerMode: string; }; const serialized = JSON.stringify(dto); for (const forbidden of [ @@ -338,9 +335,7 @@ describe("server local API auth", () => { "_codexAccountRequired", "runtime-token", "override-token", "private-work-account-id", ]) expect(serialized).not.toContain(forbidden); - expect(dto.codexAccountNamespaceCount).toBe(2); expect(dto.codexAccountNamespacesEnabled).toBe(true); - expect(dto.codexAccountNamespacePickerMode).toBe("replace-native"); expect(dto.providers.openai).toMatchObject({ adapter: "openai-chat", baseUrl: "https://api.example.test/v1", diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index 391fa7efa..2cfa374ee 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -195,27 +195,6 @@ describe("PUT /api/settings", () => { expect(res!.status).toBe(400); }); - test("account picker mode persists, refreshes the catalog, and returns to the additive default", async () => { - const config = { ...baseConfig(), codexAccountNamespaces: { personal: "main", work: "work-id" } }; - let refreshes = 0; - const refresh = async () => { refreshes += 1; }; - - const replaced = await putSettings(config, { codexAccountNamespacePickerMode: "replace-native" }, refresh); - expect(replaced!.status).toBe(200); - expect(config.codexAccountNamespacePickerMode).toBe("replace-native"); - expect(refreshes).toBe(1); - expect(await replaced!.json()).toMatchObject({ - codexAccountNamespacePickerMode: "replace-native", - codexAccountNamespaceCount: 2, - }); - - const additive = await putSettings(config, { codexAccountNamespacePickerMode: "additive" }, refresh); - expect(additive!.status).toBe(200); - expect(config.codexAccountNamespacePickerMode).toBeUndefined(); - expect(refreshes).toBe(2); - expect(loadConfig().codexAccountNamespacePickerMode).toBeUndefined(); - }); - test("account-specific model toggle creates aliases server-side and removes the feature cleanly", async () => { const config: OcxConfig = { ...baseConfig(), @@ -231,31 +210,21 @@ describe("PUT /api/settings", () => { expect(config.codexAccountNamespaces).toEqual({ personal: "main", work: "work-id" }); expect(await enabled!.json()).toMatchObject({ codexAccountNamespacesEnabled: true, - codexAccountNamespaceCount: 2, - codexAccountNamespacePickerMode: "additive", }); expect(refreshes).toBe(1); - config.codexAccountNamespacePickerMode = "replace-native"; const disabled = await putSettings(config, { codexAccountNamespacesEnabled: false }, refresh); expect(disabled!.status).toBe(200); expect(config.codexAccountNamespaces).toBeUndefined(); - expect(config.codexAccountNamespacePickerMode).toBeUndefined(); expect(await disabled!.json()).toMatchObject({ codexAccountNamespacesEnabled: false, - codexAccountNamespaceCount: 0, - codexAccountNamespacePickerMode: "additive", }); expect(refreshes).toBe(2); }); - test("account picker mode rejects invalid values and replacement without namespaces", async () => { + test("account-specific model toggle rejects non-boolean values", async () => { const config = baseConfig(); expect((await putSettings(config, { codexAccountNamespacesEnabled: "yes" }))!.status).toBe(400); - expect((await putSettings(config, { codexAccountNamespacePickerMode: "grouped" }))!.status).toBe(400); - const missing = await putSettings(config, { codexAccountNamespacePickerMode: "replace-native" }); - expect(missing!.status).toBe(400); - expect(await missing!.json()).toMatchObject({ error: expect.stringContaining("requires at least one") }); }); }); From 0dda15e2afbb161b8123398ea7011609d05ebfc3 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Sat, 25 Jul 2026 11:03:44 -0400 Subject: [PATCH 11/15] Clarify account picker purpose --- gui/src/i18n/de.ts | 8 ++++---- gui/src/i18n/en.ts | 8 ++++---- gui/src/i18n/ja.ts | 8 ++++---- gui/src/i18n/ko.ts | 8 ++++---- gui/src/i18n/ru.ts | 8 ++++---- gui/src/i18n/zh.ts | 8 ++++---- gui/tests/codex-account-picker-setting.test.tsx | 12 +++++++----- 7 files changed, 31 insertions(+), 29 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index b4abf9b35..7de51ac8d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -580,10 +580,10 @@ 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": "Konto in der Modellauswahl wählen", - "codexAuth.namespacesOnDesc": "Jedes GPT-Modell wird einmal pro Konto angezeigt. Persönlich oder Arbeit bindet den Thread strikt an dieses Konto.", - "codexAuth.namespacesOffDesc": "GPT-Modelle verwenden den oben gewählten Pool- oder Direktmodus.", - "codexAuth.namespacesCompatibilityNote": "Das aktive Pool-Konto bleibt unverändert. Einfache GPT-Einträge werden ausgeblendet; bestehende Threads und Einstellungen funktionieren weiter.", + "codexAuth.namespacesTitle": "Codex-Konten in der Modellauswahl anzeigen", + "codexAuth.namespacesOnDesc": "Die Auswahl zeigt jedes GPT-Modell einmal pro hinzugefügtem Codex-Konto. Ein kontobeschriftetes Modell verwendet für den gesamten Thread das Kontingent dieses Kontos und wechselt nie das Konto.", + "codexAuth.namespacesOffDesc": "GPT-Modelle folgen dem Pool- oder Direktverhalten oben. Aktiviere dies, um das genaue Konto für einen Thread zu wählen.", + "codexAuth.namespacesCompatibilityNote": "Dies ändert nur die Anzeige neuer Modelloptionen. Das aktive Pool-Konto, bestehende Threads und gespeicherte Einstellungen bleiben unverändert.", "codexAuth.namespacesSaved": "Kontoauswahl aktualisiert. Öffne Codex erneut, falls die Liste veraltet ist.", "codexAuth.namespacesSaveFailed": "Die Kontoauswahl konnte nicht aktualisiert werden. Die vorherige Einstellung bleibt erhalten.", "codexAuth.add": "Hinzufügen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 146f1523c..b792b418f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -881,10 +881,10 @@ 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": "Choose account in model picker", - "codexAuth.namespacesOnDesc": "Each GPT model is listed once per account. Selecting Personal or Work locks the thread to that account and never falls back.", - "codexAuth.namespacesOffDesc": "GPT models use the Pool or Direct account mode above.", - "codexAuth.namespacesCompatibilityNote": "This does not change the active Pool account. Plain GPT entries are hidden from the picker; existing threads and saved settings still work.", + "codexAuth.namespacesTitle": "Choose a Codex login per conversation", + "codexAuth.namespacesOnDesc": "The model picker shows each GPT model once for every Codex login you've added. The prefix is that login's label, such as Personal or Work. Selecting one pins the conversation to that login and its quota; OpenCodex will not switch accounts.", + "codexAuth.namespacesOffDesc": "Normally, GPT models use whichever Codex login is selected by the account mode above. Turn this on to choose the login directly from the model picker.", + "codexAuth.namespacesCompatibilityNote": "This only adds account-specific model choices. It does not change your Pool or Direct settings, existing conversations, or saved model selections.", "codexAuth.namespacesSaved": "Account choices updated. Reopen Codex if the current model list is stale.", "codexAuth.namespacesSaveFailed": "Account choices could not be updated. The previous setting is unchanged.", "codexAuth.add": "Add", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 92cb5cc78..38634eb15 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -836,10 +836,10 @@ export const ja: Record = { "codexAuth.openaiMissing": "組み込みの OpenAI プロバイダーが設定されていません。", "codexAuth.openaiDisabled": "組み込みの OpenAI プロバイダーが無効です。", "codexAuth.openProviders": "プロバイダーを開く", - "codexAuth.namespacesTitle": "モデル選択でアカウントを選ぶ", - "codexAuth.namespacesOnDesc": "各 GPT モデルがアカウントごとに表示されます。個人用または仕事用を選ぶと、そのアカウントに固定されます。", - "codexAuth.namespacesOffDesc": "GPT モデルは上のプールまたは直接アカウントモードを使用します。", - "codexAuth.namespacesCompatibilityNote": "アクティブなプールアカウントは変わりません。通常の GPT 項目は非表示ですが、既存スレッドと保存済み設定は引き続き動作します。", + "codexAuth.namespacesTitle": "モデル選択に Codex アカウントを表示", + "codexAuth.namespacesOnDesc": "追加した Codex アカウントごとに各 GPT モデルが表示されます。アカウント名付きモデルを選ぶと、スレッド全体でそのアカウントの割り当てを使い、別のアカウントへ切り替わりません。", + "codexAuth.namespacesOffDesc": "GPT モデルは上のプールまたは直接動作に従います。スレッドで使う正確なアカウントを選びたい場合に有効にします。", + "codexAuth.namespacesCompatibilityNote": "新しいモデル選択肢の表示だけが変わります。アクティブなプールアカウント、既存スレッド、保存済み設定は変わりません。", "codexAuth.namespacesSaved": "アカウント選択を更新しました。表示が古い場合は Codex を開き直してください。", "codexAuth.namespacesSaveFailed": "アカウント選択を更新できませんでした。以前の設定は維持されます。", "codexAuth.add": "追加", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 651fe84c7..5c8f4b6f2 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -599,10 +599,10 @@ export const ko: Record = { "codexAuth.openaiMissing": "내장 OpenAI 프로바이더가 설정되지 않았습니다.", "codexAuth.openaiDisabled": "내장 OpenAI 프로바이더가 비활성화되어 있습니다.", "codexAuth.openProviders": "프로바이더 열기", - "codexAuth.namespacesTitle": "모델 선택기에서 계정 선택", - "codexAuth.namespacesOnDesc": "각 GPT 모델이 계정별로 표시됩니다. 개인 또는 업무를 선택하면 해당 계정에 고정되며 대체되지 않습니다.", - "codexAuth.namespacesOffDesc": "GPT 모델은 위의 풀 또는 직접 계정 모드를 사용합니다.", - "codexAuth.namespacesCompatibilityNote": "활성 풀 계정은 변경되지 않습니다. 일반 GPT 항목은 숨겨지지만 기존 스레드와 저장된 설정은 계속 작동합니다.", + "codexAuth.namespacesTitle": "모델 선택기에 Codex 계정 표시", + "codexAuth.namespacesOnDesc": "추가한 각 Codex 계정마다 GPT 모델이 하나씩 표시됩니다. 계정 이름이 붙은 모델을 선택하면 전체 스레드에서 해당 계정의 할당량을 사용하며 계정을 전환하지 않습니다.", + "codexAuth.namespacesOffDesc": "GPT 모델은 위의 풀 또는 직접 동작을 따릅니다. 스레드에 사용할 정확한 계정을 선택하려면 켜세요.", + "codexAuth.namespacesCompatibilityNote": "새 모델 선택 항목의 표시 방식만 변경합니다. 활성 풀 계정, 기존 스레드 및 저장된 설정은 변경되지 않습니다.", "codexAuth.namespacesSaved": "계정 선택이 업데이트되었습니다. 목록이 오래된 경우 Codex를 다시 여세요.", "codexAuth.namespacesSaveFailed": "계정 선택을 업데이트하지 못했습니다. 이전 설정은 유지됩니다.", "codexAuth.add": "추가", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index e388cc402..a7fb3e9d8 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -881,10 +881,10 @@ export const ru: Record = { "codexAuth.openaiMissing": "Встроенный провайдер OpenAI не настроен.", "codexAuth.openaiDisabled": "Встроенный провайдер OpenAI отключён.", "codexAuth.openProviders": "Открыть провайдеров", - "codexAuth.namespacesTitle": "Выбирать аккаунт в списке моделей", - "codexAuth.namespacesOnDesc": "Каждая GPT-модель показана отдельно для каждого аккаунта. Выбор Личный или Рабочий строго закрепляет поток за ним.", - "codexAuth.namespacesOffDesc": "GPT-модели используют режим Пул или Прямой выше.", - "codexAuth.namespacesCompatibilityNote": "Активный аккаунт пула не меняется. Обычные GPT-пункты скрыты, но существующие потоки и настройки продолжают работать.", + "codexAuth.namespacesTitle": "Показывать аккаунты Codex в списке моделей", + "codexAuth.namespacesOnDesc": "Каждая GPT-модель показывается отдельно для каждого добавленного аккаунта Codex. Модель с именем аккаунта использует его квоту для всего потока и никогда не переключается.", + "codexAuth.namespacesOffDesc": "GPT-модели следуют режиму Пул или Прямой выше. Включите, чтобы выбрать точный аккаунт для потока.", + "codexAuth.namespacesCompatibilityNote": "Меняется только отображение новых вариантов моделей. Активный аккаунт пула, существующие потоки и сохранённые настройки не меняются.", "codexAuth.namespacesSaved": "Выбор аккаунта обновлён. Если список устарел, откройте Codex заново.", "codexAuth.namespacesSaveFailed": "Не удалось обновить выбор аккаунта. Предыдущая настройка сохранена.", "codexAuth.add": "Добавить", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c37188e10..c3b8ab914 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -599,10 +599,10 @@ export const zh: Record = { "codexAuth.openaiMissing": "未配置内置 OpenAI 提供方。", "codexAuth.openaiDisabled": "内置 OpenAI 提供方已禁用。", "codexAuth.openProviders": "打开提供商", - "codexAuth.namespacesTitle": "在模型选择器中选择账户", - "codexAuth.namespacesOnDesc": "每个 GPT 模型按账户分别显示。选择个人或工作后,线程会严格绑定该账户且不会回退。", - "codexAuth.namespacesOffDesc": "GPT 模型使用上方的账户池或直接模式。", - "codexAuth.namespacesCompatibilityNote": "不会更改当前账户池账户。普通 GPT 条目会隐藏,但现有线程和已保存设置仍然有效。", + "codexAuth.namespacesTitle": "在模型选择器中显示 Codex 账户", + "codexAuth.namespacesOnDesc": "选择器会为每个已添加的 Codex 账户分别显示每个 GPT 模型。选择带账户名称的模型后,整个线程都会使用该账户的额度,并且不会切换账户。", + "codexAuth.namespacesOffDesc": "GPT 模型遵循上方的账户池或直接模式。需要为线程选择确切账户时请开启。", + "codexAuth.namespacesCompatibilityNote": "这只会改变新模型选项的显示方式,不会更改当前账户池账户、现有线程或已保存设置。", "codexAuth.namespacesSaved": "账户选择已更新。如果列表仍旧,请重新打开 Codex。", "codexAuth.namespacesSaveFailed": "无法更新账户选择。之前的设置保持不变。", "codexAuth.add": "添加", diff --git a/gui/tests/codex-account-picker-setting.test.tsx b/gui/tests/codex-account-picker-setting.test.tsx index d675d8222..4b9ef04db 100644 --- a/gui/tests/codex-account-picker-setting.test.tsx +++ b/gui/tests/codex-account-picker-setting.test.tsx @@ -34,8 +34,8 @@ function renderSetting( describe("Codex account picker setting", () => { test("renders one intent-level off toggle without implementation terminology", () => { const html = renderSetting(false); - expect(html).toContain("Choose account in model picker"); - expect(html).toContain("GPT models use the Pool or Direct account mode above."); + expect(html).toContain("Choose a Codex login per conversation"); + expect(html).toContain("choose the login directly from the model picker"); expect(html).toContain('aria-pressed="false"'); expect(html).not.toContain("Bare + accounts"); expect(html).not.toContain("Picker layout"); @@ -43,9 +43,11 @@ describe("Codex account picker setting", () => { test("explains strict binding and compatibility when enabled", () => { const html = renderSetting(true); - expect(html).toContain("locks the thread to that account and never falls back"); - expect(html).toContain("does not change the active Pool account"); - expect(html).toContain("existing threads and saved settings still work"); + expect(html).toContain("The prefix is that login's label, such as Personal or Work"); + expect(html).toContain("pins the conversation to that login and its quota"); + expect(html).toContain("OpenCodex will not switch accounts"); + expect(html).toContain("does not change your Pool or Direct settings"); + expect(html).toContain("existing conversations, or saved model selections"); expect(html).toContain('aria-pressed="true"'); }); From c4e3496ec31bdf5ebc80fd5a8abf50e91a778ccf Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Sat, 25 Jul 2026 12:03:43 -0400 Subject: [PATCH 12/15] Generalize explicit account model selection --- .../content/docs/guides/codex-app-models.md | 25 ++-- .../src/content/docs/guides/model-routing.md | 2 +- .../docs/ja/guides/codex-app-models.md | 19 +++ .../docs/ko/guides/codex-app-models.md | 17 +++ .../content/docs/reference/configuration.md | 42 ++++-- .../docs/ru/guides/codex-app-models.md | 19 +++ .../docs/zh-cn/guides/codex-app-models.md | 15 ++ gui/src/i18n/de.ts | 12 +- gui/src/i18n/en.ts | 12 +- gui/src/i18n/ja.ts | 12 +- gui/src/i18n/ko.ts | 12 +- gui/src/i18n/ru.ts | 12 +- gui/src/i18n/zh.ts | 12 +- gui/src/pages/CodexAuth.tsx | 14 +- .../codex-account-picker-setting.test.tsx | 18 ++- src/codex/account-lifecycle.ts | 14 +- src/codex/account-namespaces.ts | 96 ++++++++++--- src/codex/auth-api.ts | 33 ++++- src/codex/auth-context.ts | 11 +- src/codex/catalog/sync.ts | 47 +++--- src/config.ts | 8 ++ src/router.ts | 12 +- src/server/auth-cors.ts | 3 +- src/server/index.ts | 8 +- src/server/management/config-routes.ts | 34 +++-- src/types.ts | 7 +- structure/08_openai-provider-tiers.md | 21 +-- tests/claude-models-discovery.test.ts | 52 ++++++- tests/codex-auth-api.test.ts | 136 +++++++++++++++++- tests/codex-auth-context.test.ts | 27 ++++ tests/codex-catalog.test.ts | 34 ++--- tests/codex-v2-gate.test.ts | 8 +- tests/combo-management-api.test.ts | 10 +- tests/config.test.ts | 63 +++++++- tests/management-provider-validation.test.ts | 10 +- tests/multi-agent-compat.test.ts | 35 ++++- tests/native-model-toggle.test.ts | 134 +++++++++++++---- tests/router.test.ts | 45 ++++-- tests/server-auth.test.ts | 27 ++-- tests/settings-stream-mode.test.ts | 62 ++++++-- 40 files changed, 911 insertions(+), 269 deletions(-) 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 1be1c3b12..e30fb776d 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -24,27 +24,32 @@ gpt-5.6-sol # openai (Pool or Direct option) openai-apikey/gpt-5.6-sol # API key ``` -For intentional per-thread account selection, turn on **Account-specific models** under -**Codex Auth**, or configure `codexAccountNamespaces` and run `ocx sync`. The picker then also shows -entries such as: +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 -personal/gpt-5.6-sol # exact main/Desktop account -work/gpt-5.6-sol # exact added work account +main/gpt-5.6-sol # exact main/Desktop account +side/gpt-5.6-sol # exact added account ``` -The opt-in replaces plain native picker rows with readable `Personal / 5.6 Sol` and -`Work / 5.6 Sol` rows. The equivalent config is: +`main` identifies the built-in Codex login. Every added account derives its selector prefix from its +user-chosen local OpenCodex ID; a numeric suffix avoids existing provider or combo prefixes. Those +IDs do not represent built-in account types. The equivalent config is: ```json { - "codexAccountNamespaces": { "personal": "main", "work": "work-account-id" } + "codexAccountNamespaces": { "main": "main", "side": "added-account-id" } } ``` 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. The **Codex Auth** -dashboard refreshes the catalog automatically after changing the toggle. +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 local ID restores those rows. An account-qualified model never switches accounts. Missing credentials, cooldown, or reauthentication fail the request. Bare models retain normal Pool/Direct behavior, and future native diff --git a/docs-site/src/content/docs/guides/model-routing.md b/docs-site/src/content/docs/guides/model-routing.md index fb03bed75..01d22a161 100644 --- a/docs-site/src/content/docs/guides/model-routing.md +++ b/docs-site/src/content/docs/guides/model-routing.md @@ -11,7 +11,7 @@ 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 `work/gpt-5.6-sol` +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. 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..893d42805 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,25 @@ 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 # ローカル ID が side の追加アカウントに固定 +``` + +`main` は組み込みログインを表します。追加アカウントのプレフィックスはユーザーが指定した +ローカル ID から生成され、既存のルーティング名と重なる場合は数字が付きます。この ID は +アカウント種別ではありません。アカウント指定モデルは Pool 選択や +フォールバックを行わず、選択したアカウントが利用できない場合は失敗します。プレフィックスなしの +`gpt-*` は従来の Pool/Direct 動作を維持します。設定を無効にしても項目が非表示になるだけで、 +保存済みのバインドは削除されません。追加アカウントを削除するとその項目は消えますが、保存済みの +選択が別アカウントへ流れないようバインドは残ります。同じローカル ID を再追加すると項目が戻ります。 +手動設定と `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..8f09168d5 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,23 @@ 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 # 로컬 ID가 side인 추가 계정에 고정 +``` + +`main`은 내장 로그인을 뜻합니다. 추가 계정의 접두사는 사용자가 정한 로컬 ID에서 만들어지고 기존 +라우팅 이름과 겹치면 숫자 접미사가 붙습니다. 이 ID는 계정 유형이 아닙니다. +계정 지정 모델은 Pool 선택과 폴백을 건너뛰며, 선택한 계정을 사용할 수 없으면 요청이 실패합니다. +접두사 없는 `gpt-*`는 기존 Pool/Direct 동작을 유지합니다. 설정을 꺼도 항목만 숨겨지고 저장된 바인딩은 +삭제되지 않습니다. 추가 계정을 삭제하면 선택기 항목은 사라지지만 저장된 선택이 다른 계정으로 넘어가지 +않도록 바인딩은 남습니다. 같은 로컬 ID를 다시 추가하면 항목이 복원됩니다. 수동 설정과 `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 c00df62b8..8bba2b9e0 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -53,6 +53,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `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 picker namespace to exact Codex account id map. Use `"main"` for the current Codex Desktop/main login. When present, account-qualified native rows replace plain GPT rows in the picker and fail closed instead of switching accounts. Plain model ids remain routable. Namespace 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. | @@ -85,34 +86,45 @@ Use `codexAccountNamespaces` when account choice must be visible and intentional ```json { "codexAccountNamespaces": { - "personal": "main", - "work": "work-account-id" + "main": "main", + "side": "added-account-id" } } ``` -The added account id is the `ID` shown by `ocx account list openai`; the namespace is a user-owned -label. After `ocx sync`, Codex exposes `personal/gpt-*` and `work/gpt-*` copies of every available +The added account id is the local `ID` shown by `ocx account list openai`; the namespace is a +user-owned picker prefix. 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 prefixes without adding the model separately to this map. -The dashboard's **Codex Auth** page exposes **Choose account in model picker** as the single opt-in. -Turning it on creates `personal` for the main/Desktop login and uses each currently added account's -alias (or id) for the other prefixes. Turning it off removes the account-qualified catalog rows. -Account ids stay server-side. Prefixes stay stable if an account alias is renamed; accounts added -later receive a new prefix automatically while the feature remains enabled. - -When enabled, plain native rows are hidden from the picker and replaced by readable entries such as -`Personal / 5.6 Sol` and `Work / 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. +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 and derives every other prefix from the added account's local ID. A numeric suffix resolves +provider or combo-prefix collisions. These names identify accounts; they do not represent built-in +account types. Turning the setting off hides the account-qualified rows while +preserving their configured bindings, so turning it back on restores the same picker identifiers. +Upstream ChatGPT account IDs and credentials stay server-side. Local picker prefixes stay stable if +an account's display alias is renamed; accounts added later receive a new prefix 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 local account ID restores the existing prefix 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 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`, 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..59d726745 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,25 @@ 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 # только добавленный аккаунт с локальным ID side +``` + +`main` обозначает встроенный вход. Префикс добавленного аккаунта создаётся из выбранного +пользователем локального ID; при конфликте с существующим маршрутом добавляется числовой суффикс. +Этот ID не является типом аккаунта. Такая модель не использует выбор или резервирование Pool: если +указанный аккаунт недоступен, запрос завершается ошибкой. ID `gpt-*` без префикса сохраняют обычное +поведение Pool/Direct. При отключении настройки строки только скрываются, а сохранённые привязки не +удаляются. При удалении добавленного аккаунта его строки исчезают, но привязка остаётся, чтобы +сохранённый выбор не перешёл на другой аккаунт. Повторное добавление того же локального ID +восстанавливает строки. Ручная настройка и `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..2c7dc3929 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,21 @@ 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 # 仅使用本地 ID 为 side 的已添加账户 +``` + +`main` 表示内置登录。已添加账户的前缀由用户指定的本地 ID 生成;如果与现有路由名称冲突,则添加 +数字后缀。这个 ID 并不代表账户类型。账户专属模型会绕过 +Pool 选择与回退;如果指定账户不可用,请求会直接失败。不带前缀的 `gpt-*` 会保留原有 Pool/Direct +行为。关闭设置只会隐藏这些条目,不会删除已保存的绑定。手动配置与 `ocx sync` 详见 +删除已添加账户后,其选择器条目会消失,但绑定会保留,确保已保存的选择不会转到其他账户。使用相同 +本地 ID 重新添加账户会恢复这些条目。手动配置与 `ocx sync` 详见[配置参考](/zh-cn/reference/configuration/)。 + ## 集成路径 `ocx init`、`ocx start` 和 `ocx sync` 会保持解析后的 `CODEX_HOME` 目录下这些文件一致: diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 7de51ac8d..a8d17f64d 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -580,12 +580,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": "Codex-Konten in der Modellauswahl anzeigen", - "codexAuth.namespacesOnDesc": "Die Auswahl zeigt jedes GPT-Modell einmal pro hinzugefügtem Codex-Konto. Ein kontobeschriftetes Modell verwendet für den gesamten Thread das Kontingent dieses Kontos und wechselt nie das Konto.", - "codexAuth.namespacesOffDesc": "GPT-Modelle folgen dem Pool- oder Direktverhalten oben. Aktiviere dies, um das genaue Konto für einen Thread zu wählen.", - "codexAuth.namespacesCompatibilityNote": "Dies ändert nur die Anzeige neuer Modelloptionen. Das aktive Pool-Konto, bestehende Threads und gespeicherte Einstellungen bleiben unverändert.", - "codexAuth.namespacesSaved": "Kontoauswahl aktualisiert. Öffne Codex erneut, falls die Liste veraltet ist.", - "codexAuth.namespacesSaveFailed": "Die Kontoauswahl konnte nicht aktualisiert werden. Die vorherige Einstellung bleibt erhalten.", + "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. Die Bezeichnung hinzugefügter Konten wird aus der lokalen ID auf ihrer Karte abgeleitet; der integrierte Login beginnt mit Main. Bei Namenskonflikten wird eine Zahl angehängt. 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 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…", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index b792b418f..880090b6f 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -881,12 +881,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": "Choose a Codex login per conversation", - "codexAuth.namespacesOnDesc": "The model picker shows each GPT model once for every Codex login you've added. The prefix is that login's label, such as Personal or Work. Selecting one pins the conversation to that login and its quota; OpenCodex will not switch accounts.", - "codexAuth.namespacesOffDesc": "Normally, GPT models use whichever Codex login is selected by the account mode above. Turn this on to choose the login directly from the model picker.", - "codexAuth.namespacesCompatibilityNote": "This only adds account-specific model choices. It does not change your Pool or Direct settings, existing conversations, or saved model selections.", - "codexAuth.namespacesSaved": "Account choices updated. Reopen Codex if the current model list is stale.", - "codexAuth.namespacesSaveFailed": "Account choices could not be updated. The previous setting is unchanged.", + "codexAuth.namespacesTitle": "Show each Codex account separately in the model picker", + "codexAuth.namespacesOnDesc": "Each available Codex account now gets a separate entry for every GPT model. Added accounts use a picker label derived from the local ID shown on their card; the built-in login starts with Main. A numeric suffix resolves any label collision. 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 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...", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 38634eb15..b7a64332c 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -836,12 +836,12 @@ export const ja: Record = { "codexAuth.openaiMissing": "組み込みの OpenAI プロバイダーが設定されていません。", "codexAuth.openaiDisabled": "組み込みの OpenAI プロバイダーが無効です。", "codexAuth.openProviders": "プロバイダーを開く", - "codexAuth.namespacesTitle": "モデル選択に Codex アカウントを表示", - "codexAuth.namespacesOnDesc": "追加した Codex アカウントごとに各 GPT モデルが表示されます。アカウント名付きモデルを選ぶと、スレッド全体でそのアカウントの割り当てを使い、別のアカウントへ切り替わりません。", - "codexAuth.namespacesOffDesc": "GPT モデルは上のプールまたは直接動作に従います。スレッドで使う正確なアカウントを選びたい場合に有効にします。", - "codexAuth.namespacesCompatibilityNote": "新しいモデル選択肢の表示だけが変わります。アクティブなプールアカウント、既存スレッド、保存済み設定は変わりません。", - "codexAuth.namespacesSaved": "アカウント選択を更新しました。表示が古い場合は Codex を開き直してください。", - "codexAuth.namespacesSaveFailed": "アカウント選択を更新できませんでした。以前の設定は維持されます。", + "codexAuth.namespacesTitle": "各 Codex アカウントをモデル選択に個別表示", + "codexAuth.namespacesOnDesc": "利用可能な Codex アカウントごとに各 GPT モデルが個別に表示されます。追加アカウントのラベルはカードに表示されたローカル ID から作られ、組み込みログインは Main から始まります。ラベルが重なる場合は数字が付きます。選択した会話はそのアカウントに固定され、OpenCodex が別のアカウントへ切り替えたりフォールバックしたりすることはありません。", + "codexAuth.namespacesOffDesc": "複数の Codex アカウントから、ログアウトせずに会話で使うアカウントを明示的に選びたい場合に有効にします。無効にしてもこれらの項目が非表示になるだけで、アカウントは削除されません。", + "codexAuth.namespacesCompatibilityNote": "モデル選択の通常の GPT 項目を、アカウント別の明示的な項目に置き換えます。既存の会話と保存済みのモデル選択は現在のルーティングを維持し、プレフィックスなしの GPT モデル ID は従来のプールまたは直接動作を維持します。", + "codexAuth.namespacesSaved": "アカウント別のモデル選択項目を更新しました。表示が古い場合は Codex を開き直してください。", + "codexAuth.namespacesSaveFailed": "モデル選択を更新できませんでした。以前の設定は維持されます。", "codexAuth.add": "追加", "codexAuth.refreshQuota": "クォータを更新", "codexAuth.refreshingQuota": "更新中...", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5c8f4b6f2..f4a4aa6a4 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -599,12 +599,12 @@ export const ko: Record = { "codexAuth.openaiMissing": "내장 OpenAI 프로바이더가 설정되지 않았습니다.", "codexAuth.openaiDisabled": "내장 OpenAI 프로바이더가 비활성화되어 있습니다.", "codexAuth.openProviders": "프로바이더 열기", - "codexAuth.namespacesTitle": "모델 선택기에 Codex 계정 표시", - "codexAuth.namespacesOnDesc": "추가한 각 Codex 계정마다 GPT 모델이 하나씩 표시됩니다. 계정 이름이 붙은 모델을 선택하면 전체 스레드에서 해당 계정의 할당량을 사용하며 계정을 전환하지 않습니다.", - "codexAuth.namespacesOffDesc": "GPT 모델은 위의 풀 또는 직접 동작을 따릅니다. 스레드에 사용할 정확한 계정을 선택하려면 켜세요.", - "codexAuth.namespacesCompatibilityNote": "새 모델 선택 항목의 표시 방식만 변경합니다. 활성 풀 계정, 기존 스레드 및 저장된 설정은 변경되지 않습니다.", - "codexAuth.namespacesSaved": "계정 선택이 업데이트되었습니다. 목록이 오래된 경우 Codex를 다시 여세요.", - "codexAuth.namespacesSaveFailed": "계정 선택을 업데이트하지 못했습니다. 이전 설정은 유지됩니다.", + "codexAuth.namespacesTitle": "각 Codex 계정을 모델 선택기에 별도로 표시", + "codexAuth.namespacesOnDesc": "사용 가능한 Codex 계정마다 각 GPT 모델이 별도 항목으로 표시됩니다. 추가 계정의 라벨은 카드에 표시된 로컬 ID에서 만들어지고 기본 제공 로그인은 Main으로 시작합니다. 라벨이 겹치면 숫자 접미사가 붙습니다. 선택한 대화는 해당 계정에 고정되고 OpenCodex는 다른 계정으로 전환하거나 대체하지 않습니다.", + "codexAuth.namespacesOffDesc": "여러 Codex 계정 중 대화에 사용할 계정을 로그아웃 없이 명시적으로 선택하려면 켜세요. 꺼도 이 항목만 숨겨지며 계정은 삭제되지 않습니다.", + "codexAuth.namespacesCompatibilityNote": "모델 선택기의 일반 GPT 항목을 명시적인 계정별 항목으로 바꿉니다. 기존 대화와 저장된 모델 선택은 현재 라우팅을 유지하고, 접두사 없는 GPT 모델 ID는 기존 풀 또는 직접 동작을 유지합니다.", + "codexAuth.namespacesSaved": "계정별 모델 선택 항목이 업데이트되었습니다. 목록이 오래된 경우 Codex를 다시 여세요.", + "codexAuth.namespacesSaveFailed": "모델 선택기를 업데이트하지 못했습니다. 이전 설정은 유지됩니다.", "codexAuth.add": "추가", "codexAuth.refreshQuota": "할당량 새로고침", "codexAuth.refreshingQuota": "새로고침 중...", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index a7fb3e9d8..531e71f60 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -881,12 +881,12 @@ export const ru: Record = { "codexAuth.openaiMissing": "Встроенный провайдер OpenAI не настроен.", "codexAuth.openaiDisabled": "Встроенный провайдер OpenAI отключён.", "codexAuth.openProviders": "Открыть провайдеров", - "codexAuth.namespacesTitle": "Показывать аккаунты Codex в списке моделей", - "codexAuth.namespacesOnDesc": "Каждая GPT-модель показывается отдельно для каждого добавленного аккаунта Codex. Модель с именем аккаунта использует его квоту для всего потока и никогда не переключается.", - "codexAuth.namespacesOffDesc": "GPT-модели следуют режиму Пул или Прямой выше. Включите, чтобы выбрать точный аккаунт для потока.", - "codexAuth.namespacesCompatibilityNote": "Меняется только отображение новых вариантов моделей. Активный аккаунт пула, существующие потоки и сохранённые настройки не меняются.", - "codexAuth.namespacesSaved": "Выбор аккаунта обновлён. Если список устарел, откройте Codex заново.", - "codexAuth.namespacesSaveFailed": "Не удалось обновить выбор аккаунта. Предыдущая настройка сохранена.", + "codexAuth.namespacesTitle": "Показывать каждый аккаунт Codex отдельно в списке моделей", + "codexAuth.namespacesOnDesc": "Для каждого доступного аккаунта Codex каждая GPT-модель показывается отдельно. Метка добавленного аккаунта создаётся из локального ID на его карточке, а метка встроенного входа начинается с Main. При совпадении добавляется числовой суффикс. Выбранный диалог закрепляется за этим аккаунтом; OpenCodex не переключается и не использует другой аккаунт как резервный.", + "codexAuth.namespacesOffDesc": "Включите, если у вас несколько аккаунтов Codex и вы хотите явно выбрать аккаунт для диалога без выхода из системы. При отключении эти строки только скрываются; аккаунты не удаляются.", + "codexAuth.namespacesCompatibilityNote": "Обычные строки GPT в списке моделей заменяются явными вариантами для каждого аккаунта. Существующие диалоги и сохранённый выбор модели сохраняют текущее направление, а ID GPT без префикса — режим Пул или Прямой.", + "codexAuth.namespacesSaved": "Отображение аккаунтов в списке моделей обновлено. Если список устарел, откройте Codex заново.", + "codexAuth.namespacesSaveFailed": "Не удалось обновить список моделей. Предыдущая настройка сохранена.", "codexAuth.add": "Добавить", "codexAuth.refreshQuota": "Обновить квоты", "codexAuth.refreshingQuota": "Обновление...", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c3b8ab914..6bffe4e61 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -599,12 +599,12 @@ export const zh: Record = { "codexAuth.openaiMissing": "未配置内置 OpenAI 提供方。", "codexAuth.openaiDisabled": "内置 OpenAI 提供方已禁用。", "codexAuth.openProviders": "打开提供商", - "codexAuth.namespacesTitle": "在模型选择器中显示 Codex 账户", - "codexAuth.namespacesOnDesc": "选择器会为每个已添加的 Codex 账户分别显示每个 GPT 模型。选择带账户名称的模型后,整个线程都会使用该账户的额度,并且不会切换账户。", - "codexAuth.namespacesOffDesc": "GPT 模型遵循上方的账户池或直接模式。需要为线程选择确切账户时请开启。", - "codexAuth.namespacesCompatibilityNote": "这只会改变新模型选项的显示方式,不会更改当前账户池账户、现有线程或已保存设置。", - "codexAuth.namespacesSaved": "账户选择已更新。如果列表仍旧,请重新打开 Codex。", - "codexAuth.namespacesSaveFailed": "无法更新账户选择。之前的设置保持不变。", + "codexAuth.namespacesTitle": "在模型选择器中分别显示每个 Codex 账户", + "codexAuth.namespacesOnDesc": "每个可用的 Codex 账户都会单独显示一份各 GPT 模型。已添加账户的标签由其卡片上显示的本地 ID 生成,内置登录的标签以 Main 开头;如有重名则添加数字后缀。选择后,对话会固定使用该账户;OpenCodex 不会切换或回退到其他账户。", + "codexAuth.namespacesOffDesc": "如果你有多个 Codex 账户,并希望无需退出登录即可明确选择对话使用的账户,请开启此项。关闭后只会隐藏这些选项,不会删除任何账户。", + "codexAuth.namespacesCompatibilityNote": "这会用明确的账户专属选项替换模型选择器中的常规 GPT 项。现有对话和已保存的模型选择会保持当前路由,不带前缀的 GPT 模型 ID 会继续遵循原有的账户池或直接模式。", + "codexAuth.namespacesSaved": "模型选择器中的账户显示已更新。如果列表仍旧,请重新打开 Codex。", + "codexAuth.namespacesSaveFailed": "无法更新模型选择器。之前的设置保持不变。", "codexAuth.add": "添加", "codexAuth.refreshQuota": "刷新额度", "codexAuth.refreshingQuota": "刷新中...", diff --git a/gui/src/pages/CodexAuth.tsx b/gui/src/pages/CodexAuth.tsx index 43ad83c49..0874c0b3f 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -19,12 +19,14 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { const loadMode = useCallback(async () => { try { - const config = await fetch(`${apiBase}/api/config`).then(r => r.json()) as { - codexAccountNamespacesEnabled?: boolean; + 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.codexAccountNamespacesEnabled === true); + setNamespacesEnabled(config.codexAccountPickerEnabled === true); } } catch { /* banner degrades to no badge */ } }, [apiBase]); @@ -46,14 +48,14 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { const response = await fetch(`${apiBase}/api/settings`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ codexAccountNamespacesEnabled: next }), + body: JSON.stringify({ codexAccountPickerEnabled: next }), }); const result = await response.json().catch(() => ({})) as { error?: string; - codexAccountNamespacesEnabled?: boolean; + codexAccountPickerEnabled?: boolean; }; if (!response.ok) throw new Error(result.error ?? "save failed"); - setNamespacesEnabled(result.codexAccountNamespacesEnabled === true); + setNamespacesEnabled(result.codexAccountPickerEnabled === true); setNamespaceFeedback({ tone: "ok", message: t("codexAuth.namespacesSaved") }); } catch { setNamespacesEnabled(previousEnabled); diff --git a/gui/tests/codex-account-picker-setting.test.tsx b/gui/tests/codex-account-picker-setting.test.tsx index 4b9ef04db..e6efb1138 100644 --- a/gui/tests/codex-account-picker-setting.test.tsx +++ b/gui/tests/codex-account-picker-setting.test.tsx @@ -34,8 +34,9 @@ function renderSetting( describe("Codex account picker setting", () => { test("renders one intent-level off toggle without implementation terminology", () => { const html = renderSetting(false); - expect(html).toContain("Choose a Codex login per conversation"); - expect(html).toContain("choose the login directly from the model picker"); + 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"); @@ -43,11 +44,14 @@ describe("Codex account picker setting", () => { test("explains strict binding and compatibility when enabled", () => { const html = renderSetting(true); - expect(html).toContain("The prefix is that login's label, such as Personal or Work"); - expect(html).toContain("pins the conversation to that login and its quota"); - expect(html).toContain("OpenCodex will not switch accounts"); - expect(html).toContain("does not change your Pool or Direct settings"); - expect(html).toContain("existing conversations, or saved model selections"); + expect(html).toContain("picker label derived from the local ID shown on their card"); + expect(html).toContain("the built-in login starts with Main"); + expect(html).toContain("numeric suffix resolves any label collision"); + 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("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"'); }); diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index f83dabcee..8ff4f1555 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -4,6 +4,7 @@ import { clearAccountQuota } from "./quota"; import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } from "./routing"; import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; import type { OcxConfig } from "../types"; +import { visibleCodexAccountNamespaceEntries } from "./account-namespaces"; export function purgeCodexAccountRuntimeState(accountId: string): void { clearAccountNeedsReauth(accountId); @@ -13,19 +14,12 @@ export function purgeCodexAccountRuntimeState(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); - let namespaceRemoved = false; - if (runtimeConfig.codexAccountNamespaces) { - const previousCount = Object.keys(runtimeConfig.codexAccountNamespaces).length; - runtimeConfig.codexAccountNamespaces = Object.fromEntries( - Object.entries(runtimeConfig.codexAccountNamespaces) - .filter(([, boundAccountId]) => boundAccountId !== accountId), - ); - namespaceRemoved = Object.keys(runtimeConfig.codexAccountNamespaces).length !== previousCount; - } if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); - return namespaceRemoved; + return visibleBindingRemoved; } diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts index c5168c963..dbdd1c34b 100644 --- a/src/codex/account-namespaces.ts +++ b/src/codex/account-namespaces.ts @@ -7,19 +7,57 @@ export const CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION = "OpenAI native model boun const RESERVED_NAMESPACE_KEYS = new Set(["__proto__", "prototype", "constructor", "combo"]); -function generatedNamespace(label: string): string { - const normalized = label.trim().toLowerCase() +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(localId: string): string { + const normalized = localId.trim().toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/^[._-]+|[._-]+$/g, ""); return normalized && !RESERVED_NAMESPACE_KEYS.has(normalized) ? normalized : "account"; } -/** Build the initial UI-managed namespace map without exposing account ids to the browser. */ +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)] : []; + }); +} + +/** Build the initial UI-managed map from local ids without exposing upstream account identity. */ export function defaultCodexAccountNamespaces( - config: Pick, + config: Pick, ): Record { const namespaces: Record = {}; - const used = new Set([...Object.keys(config.providers), ...RESERVED_NAMESPACE_KEYS]); + 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; @@ -29,27 +67,31 @@ export function defaultCodexAccountNamespaces( return candidate; }; - namespaces[claim("personal")] = MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET; + namespaces[claim("main")] = MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET; for (const account of config.codexAccounts ?? []) { - if (account.isMain) continue; - namespaces[claim(account.alias || account.id)] = account.id; + if (account.isMain || isMainCodexAccountTarget(account.id)) continue; + namespaces[claim(account.id)] = account.id; } return namespaces; } -/** Add a newly stored account to an already-enabled UI-managed picker without renaming old rows. */ +/** 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; isMain: boolean }, + config: Pick, + account: { id: string; isMain: boolean }, ): boolean { - if (account.isMain || !config.codexAccountNamespaces) return false; + 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(account.alias || account.id); + const base = generatedNamespace(account.id); let namespace = base; let suffix = 2; while (used.has(namespace)) namespace = `${base}-${suffix++}`; @@ -59,10 +101,9 @@ export function appendDefaultCodexAccountNamespace( export function codexAccountNamespaceDisplayName(namespace: string): string { return namespace - .split(/[._-]+/) - .filter(Boolean) - .map(part => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); + .split(/([._-]+)/) + .map(part => /^[._-]+$/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1)) + .join(""); } export function accountBoundNativeDisplayName(namespace: string, native: { @@ -92,22 +133,35 @@ export function accountBoundNativeCatalogSlug(entry: { 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 accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || accountId === MAIN_CODEX_ACCOUNT_ID + return isMainCodexAccountTarget(accountId) ? MAIN_CODEX_ACCOUNT_ID : accountId; } -export function codexAccountNamespaceEntries(config: Pick): Array<[string, string]> { +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, + config: Pick, nativeSlugs: Iterable, ): string[] { const natives = [...nativeSlugs]; - return codexAccountNamespaceEntries(config) + 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 d58945cf3..a27d465c8 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -10,7 +10,11 @@ import { TokenRefreshError, } from "./account-store"; import { deleteCodexAccount } from "./account-lifecycle"; -import { appendDefaultCodexAccountNamespace } from "./account-namespaces"; +import { + appendDefaultCodexAccountNamespace, + codexAccountPickerIsEnabled, + isMainCodexAccountTarget, +} from "./account-namespaces"; import { checkAccountIdCollision, readCodexTokens } from "./auth-collision"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; @@ -421,7 +425,7 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa } async function refreshAccountNamespaceCatalog(config: OcxConfig, changed: boolean): Promise { - if (!changed) return; + if (!changed || !codexAccountPickerIsEnabled(config)) return; try { const { refreshCodexModelCatalog } = await import("./refresh"); await refreshCodexModelCatalog(config); @@ -452,6 +456,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); } @@ -480,11 +487,13 @@ export async function handleCodexAuthAPI( markCodexAccountValidated(body.id, warmup.validatedAt); clearAccountNeedsReauth(body.id); 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); - await refreshAccountNamespaceCatalog(runtimeConfig, namespaceAdded); + await refreshAccountNamespaceCatalog(runtimeConfig, namespaceAdded || retainedPickerBindingRestored); return jsonResponse({ ok: true }); } @@ -492,9 +501,9 @@ export async function handleCodexAuthAPI( const id = url.searchParams.get("id"); if (!id) return jsonResponse({ error: "Missing id" }, 400); const runtimeConfig = getRuntimeConfig(config); - const namespaceRemoved = deleteCodexAccount(runtimeConfig, id); + const pickerChanged = deleteCodexAccount(runtimeConfig, id); saveRuntimeConfig(config, runtimeConfig); - await refreshAccountNamespaceCatalog(runtimeConfig, namespaceRemoved); + await refreshAccountNamespaceCatalog(runtimeConfig, pickerChanged); return jsonResponse({ ok: true }); } @@ -646,13 +655,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); } } @@ -794,11 +811,13 @@ export async function handleCodexAuthAPI( saveRuntimeConfig(config, latestConfig); } else { 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); - await refreshAccountNamespaceCatalog(latestConfig, namespaceAdded); + await refreshAccountNamespaceCatalog(latestConfig, namespaceAdded || retainedPickerBindingRestored); } codexAuthLoginState.set(flowId, { status: "done", accountId, email, doneAt: Date.now() }); completed = true; diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index b058ddeaa..daa1cb604 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -107,13 +107,16 @@ export async function resolveCodexAuthContext( mode: CodexAccountMode, options: ResolveCodexAuthContextOptions = {}, ): Promise { - if (mode === "direct") { - if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError(); - return { kind: "main", accountId: null }; - } 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 }; + } const threadId = headers.get("x-codex-parent-thread-id"); const resolution = options.accountId ? { status: "selected" as const, accountId: options.accountId } diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 34b828a10..f5ef6cbb9 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -33,7 +33,9 @@ import { accountBoundNativeDisplayName, accountBoundNativeCatalogSlug, CODEX_ACCOUNT_BOUND_CATALOG_DESCRIPTION, - codexAccountNamespaceEntries, + visibleCodexAccountNamespaceEntries, + codexAccountPickerHasVisibleRows, + visibleCodexAccountNamespaces, } from "../account-namespaces"; @@ -120,19 +122,32 @@ export function effectiveSubagentRoster( })); const excluded = configured.flatMap((model): SubagentRosterExclusion[] => { if (ordered.some(({ entry }) => configuredSubagentModelMatchesEntry(model, entry))) return []; - 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" }]; + 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 }; } @@ -537,7 +552,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num websocketsEnabled(config), multiAgentMode, exactComboSlugs, - config.codexAccountNamespaces, + visibleCodexAccountNamespaces(config), ); const accountBoundEntries = buildCatalogEntries( template ? JSON.parse(JSON.stringify(template)) : null, @@ -547,7 +562,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num websocketsEnabled(config), multiAgentMode, exactComboSlugs, - config.codexAccountNamespaces, + visibleCodexAccountNamespaces(config), ).filter(entry => accountBoundNativeCatalogSlug(entry) !== undefined); const goEntries = [...routedEntries, ...accountBoundEntries]; // Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append @@ -560,12 +575,12 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num .filter(([, prov]) => prov.disabled !== true) .map(([name]) => name), ); - for (const [namespace] of codexAccountNamespaceEntries(config)) gatheredProviderNames.add(namespace); + 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, Object.keys(config.codexAccountNamespaces ?? {}).length > 0); + catalog.models = mergeCatalogEntriesForSync(catalog.models ?? [], goEntries, baseline, featured, wsEnabled, goIds, template, disabledNativeSlugs(config), gatheredProviderNames, multiAgentMode, exactComboSlugs, hasPhysicalComboProvider, codexAccountPickerHasVisibleRows(config)); clampCatalogModelsToCodexSupport(catalog.models); atomicWriteFile(catalogPath, JSON.stringify(catalog, null, 2) + "\n"); diff --git a/src/config.ts b/src/config.ts index d13d9027f..61773c131 100644 --- a/src/config.ts +++ b/src/config.ts @@ -449,6 +449,14 @@ 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)) { diff --git a/src/router.ts b/src/router.ts index dff5f5b84..d477cc77f 100644 --- a/src/router.ts +++ b/src/router.ts @@ -3,7 +3,13 @@ import { COMBO_NAMESPACE, tryPickComboModel, type ComboPick } from "./combos"; 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"; @@ -250,7 +256,9 @@ function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: bo 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) throw new NoEnabledOpenAiProviderError(nativeModelId); + if (!provider || provider.disabled === true || !isCanonicalOpenAiForwardProvider(provider)) { + throw new NoEnabledOpenAiProviderError(nativeModelId); + } const routed = routeResult(OPENAI_CODEX_PROVIDER_ID, provider, nativeModelId); return { ...routed, diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index cf6e218ce..3fac60c3a 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -13,6 +13,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}`; } @@ -332,7 +333,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { hostname: config.hostname ?? "127.0.0.1", defaultProvider: config.defaultProvider, codexAutoStart: codexAutoStartEnabled(config), - codexAccountNamespacesEnabled: Object.keys(config.codexAccountNamespaces ?? {}).length > 0, + codexAccountPickerEnabled: codexAccountPickerIsEnabled(config), websockets: config.websockets, providers, }; diff --git a/src/server/index.ts b/src/server/index.ts index 08853376c..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"; @@ -390,12 +394,12 @@ 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), config.codexAccountNamespaces); + 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), - Object.keys(config.codexAccountNamespaces ?? {}).length > 0, + codexAccountPickerHasVisibleRows(config), ), }, 200, req, config); } diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 5f245965f..7fd935d9f 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -34,7 +34,10 @@ import { clearThreadAccountMap } from "../../codex/routing"; 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"; -import { defaultCodexAccountNamespaces } from "../../codex/account-namespaces"; +import { + codexAccountPickerIsEnabled, + defaultCodexAccountNamespaces, +} from "../../codex/account-namespaces"; import { scanStorage } from "../../storage/scanner"; import { readUsageEntries } from "../../usage/log"; import { getUsageDebugLogEntries } from "../../usage/debug"; @@ -191,20 +194,20 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise 0; - if (body.codexAccountNamespacesEnabled === true && !namespacesWereEnabled) { - config.codexAccountNamespaces = defaultCodexAccountNamespaces(config); - } else if (body.codexAccountNamespacesEnabled === false) { - delete config.codexAccountNamespaces; + const pickerWasEnabled = codexAccountPickerIsEnabled(config); + if (body.codexAccountPickerEnabled === true) { + if (Object.keys(config.codexAccountNamespaces ?? {}).length === 0) { + config.codexAccountNamespaces = defaultCodexAccountNamespaces(config); + } + config.codexAccountPickerEnabled = true; + } else if (body.codexAccountPickerEnabled === false) { + config.codexAccountPickerEnabled = false; } - const namespacesAreEnabled = Object.keys(config.codexAccountNamespaces ?? {}).length > 0; + const pickerIsEnabled = codexAccountPickerIsEnabled(config); saveConfig(config); - if (namespacesWereEnabled !== namespacesAreEnabled) { + if (pickerWasEnabled !== pickerIsEnabled) { await refreshCodexCatalogBestEffort(); } invalidateStartupHealthCache(); @@ -235,7 +241,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise; + /** + * 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. */ diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 7a5680270..87b5cd57a 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -25,14 +25,17 @@ bypass Pool/Direct selection, quota balancing, failure failover, thread affinity model account retry. Cooldown, missing credentials, and reauthentication fail closed on the bound account and do not mutate the active pool selection. -The namespace map is the picker opt-in. When present, friendly account-specific rows replace plain -native rows in the picker and remain grouped ahead of ordinary routed providers. Plain ids remain -routable and keep their Pool/Direct semantics for saved threads and configuration. +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 -personal/gpt-5.6-sol # exact main/Desktop Codex account -work/gpt-5.6-sol # exact added Codex account +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 ``` @@ -66,8 +69,8 @@ v2 backup blocks migration before save. 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. Account ids never appear in the catalog or request logs; - only the user-owned namespace is displayed. + 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 @@ -84,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 9a6cbf000..1b5d805a8 100644 --- a/tests/claude-models-discovery.test.ts +++ b/tests/claude-models-discovery.test.ts @@ -168,23 +168,63 @@ test("account-qualified native models appear in both OpenAI and Codex discovery" authMode: "forward", }, }, - codexAccountNamespaces: { personal: "main", work: "opaque-work-account-id" }, + 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("personal/gpt-"))).toBe(true); - expect(plain.data.some(model => model.id.startsWith("work/gpt-"))).toBe(true); + 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("personal/gpt-"))).toBe(true); - expect(bound.some(model => model.slug?.startsWith("work/gpt-"))).toBe(true); - expect(JSON.stringify(codex)).not.toContain("opaque-work-account-id"); + 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 d32aa6bdd..a247f6f44 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,36 @@ describe("codex-auth API", () => { expect(warmup.calls()).toBe(1); }); + 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,9 +930,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: { work: "pool-delete", personal: "main" }, + codexAccountNamespaces: { side: "pool-delete", main: "main" }, + codexAccountPickerEnabled: true, }); saveCodexAccountCredential("pool-delete", { accessToken: "access-delete", @@ -922,7 +983,8 @@ describe("codex-auth API", () => { expect(resp!.status).toBe(200); expect(config.codexAccounts).toEqual([]); expect(config.activeCodexAccountId).toBeUndefined(); - expect(config.codexAccountNamespaces).toEqual({ personal: "main" }); + 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); @@ -931,6 +993,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 () => { @@ -1132,6 +1226,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 8370948c4..4d3eb7c73 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -96,6 +96,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", diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 412395fe1..a13be8715 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -1012,22 +1012,22 @@ describe("Codex catalog routed normalization", () => { false, "default", new Set(), - { personal: "main", work: "opaque-account-id" }, + { main: "main", side: "opaque-account-id" }, ); const native = entries.find(entry => entry.slug === "gpt-5.5"); - const personal = entries.find(entry => entry.slug === "personal/gpt-5.5"); - const work = entries.find(entry => entry.slug === "work/gpt-5.5"); - - expect(personal).toBeDefined(); - expect(work).toBeDefined(); - expect(personal?.description).toBe("OpenAI native model bound to a Codex account namespace."); - expect(personal?.model_messages).toEqual(native?.model_messages); - expect(personal?.supported_reasoning_levels).toEqual(native?.supported_reasoning_levels); - expect(personal?.input_modalities).toEqual(native?.input_modalities); - expect(personal?.service_tiers).toEqual(native?.service_tiers); - expect(personal?.display_name).toBe("Personal / 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(personal?.priority)).toBe(true); + expect(Number.isInteger(main?.priority)).toBe(true); expect(JSON.stringify(entries)).not.toContain("opaque-account-id"); }); @@ -1036,14 +1036,14 @@ describe("Codex catalog routed normalization", () => { nativeTemplate(), ["gpt-5.5"], [], - ["work/gpt-5.5"], + ["side/gpt-5.5"], false, "default", new Set(), - { personal: "main", work: "work-account-id" }, + { main: "main", side: "side-account-id" }, ); - expect(entries.find(entry => entry.slug === "work/gpt-5.5")?.priority).toBe(0); - expect(entries.find(entry => entry.slug === "personal/gpt-5.5")?.priority).toBeGreaterThan(0); + 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"); }); diff --git a/tests/codex-v2-gate.test.ts b/tests/codex-v2-gate.test.ts index 15aa740e5..ddcf5af4b 100644 --- a/tests/codex-v2-gate.test.ts +++ b/tests/codex-v2-gate.test.ts @@ -552,12 +552,12 @@ describe("3-state multi-agent mode", () => { false, "default", new Set(), - { work: "work-account-id" }, + { side: "side-account-id" }, ); - expect(entries.find(entry => entry.slug === "work/gpt-5.6-sol")?.multi_agent_version).toBe("v2"); - expect(entries.find(entry => entry.slug === "work/gpt-5.6-terra")?.multi_agent_version).toBe("v2"); - expect(entries.find(entry => entry.slug === "work/gpt-5.6-luna")?.multi_agent_version).toBe("v1"); + 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", () => { diff --git a/tests/combo-management-api.test.ts b/tests/combo-management-api.test.ts index 2ca916ced..e3947f36e 100644 --- a/tests/combo-management-api.test.ts +++ b/tests/combo-management-api.test.ts @@ -296,12 +296,12 @@ 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: { work: "work-account-id" } }); + const config = baseConfig({ codexAccountNamespaces: { side: "side-account-id" } }); saveConfig(config); const response = await comboApi(config, "PUT", "/api/combos", { id: "intentional", - combo: { ...VALID_COMBO, alias: "work/gpt-5.5" }, + combo: { ...VALID_COMBO, alias: "side/gpt-5.5" }, }); expect(response?.status).toBe(400); @@ -309,7 +309,7 @@ describe("combo management API", () => { error: "combo alias must not use a configured Codex account namespace", }); expect(config.combos?.intentional).toBeUndefined(); - expect(readConfigDiagnostics().config.codexAccountNamespaces).toEqual({ work: "work-account-id" }); + expect(readConfigDiagnostics().config.codexAccountNamespaces).toEqual({ side: "side-account-id" }); }); }); @@ -761,7 +761,7 @@ describe("combo management API", () => { combos: { fast: { alias: "fast-chat", targets: [{ provider: "a", model: "m1" }] }, }, - codexAccountNamespaces: { work: "main" }, + codexAccountNamespaces: { side: "main" }, }); await syncCatalogModels(config); @@ -770,7 +770,7 @@ describe("combo management API", () => { }; const slugs = catalog.models.map(model => model.slug); expect(slugs).toContain("fast-chat"); - expect(slugs).toContain("work/gpt-5.5"); + expect(slugs).toContain("side/gpt-5.5"); } finally { if (previousCodexHome === undefined) delete process.env.CODEX_HOME; else process.env.CODEX_HOME = previousCodexHome; diff --git a/tests/config.test.ts b/tests/config.test.ts index 7d9179980..272835fd1 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -715,20 +715,45 @@ describe("opencodex config defaults", () => { }, }, defaultProvider: "openai", - codexAccountNamespaces: { personal: "main", work: "work-account-id" }, + codexAccountNamespaces: { main: "main", side: "side-account-id" }, + codexAccountPickerEnabled: false, }); - expect(loadConfig().codexAccountNamespaces).toEqual({ personal: "main", work: "work-account-id" }); + 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: { - work: { adapter: "openai-chat", baseUrl: "https://work.example.test/v1" }, + side: { adapter: "openai-chat", baseUrl: "https://side.example.test/v1" }, }, - defaultProvider: "work", - codexAccountNamespaces: { work: "work-account-id" }, + defaultProvider: "side", + codexAccountNamespaces: { side: "side-account-id" }, }); const errorSpy = spyOn(console, "error").mockImplementation(() => {}); try { @@ -746,7 +771,7 @@ describe("opencodex config defaults", () => { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, }, defaultProvider: "openai", - codexAccountNamespaces: { combo: "work-account-id" }, + codexAccountNamespaces: { combo: "side-account-id" }, }); const errorSpy = spyOn(console, "error").mockImplementation(() => {}); try { @@ -757,6 +782,32 @@ describe("opencodex config defaults", () => { } }); + 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 0d261218c..f3a06202d 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -449,7 +449,7 @@ describe("provider management validation", () => { process.env.OPENCODEX_HOME = TEST_DIR; const cfg = { ...config("127.0.0.1"), - codexAccountNamespaces: { work: "work-account-id" }, + codexAccountNamespaces: { side: "side-account-id" }, }; saveConfig(cfg); @@ -459,10 +459,10 @@ describe("provider management validation", () => { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - name: "work", + name: "side", provider: { adapter: "openai-chat", - baseUrl: "https://work.example.test/v1", + baseUrl: "https://side.example.test/v1", }, }), }), @@ -475,8 +475,8 @@ describe("provider management validation", () => { expect(await response?.json()).toEqual({ error: "provider name must not collide with a configured Codex account namespace", }); - expect(cfg.providers.work).toBeUndefined(); - expect(loadConfig().codexAccountNamespaces).toEqual({ work: "work-account-id" }); + expect(cfg.providers.side).toBeUndefined(); + expect(loadConfig().codexAccountNamespaces).toEqual({ side: "side-account-id" }); }); test("provider management rejects base URLs with embedded credentials", async () => { diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index 261ae4859..c65d9e92a 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -163,18 +163,45 @@ describe("multiAgentGuidanceText", () => { 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: "personal/gpt-5.6-sol", priority: 0, multiAgentVersion: "v2", description: boundDescription }, - { slug: "work/gpt-5.6-sol", priority: 0.33, multiAgentVersion: "v2", description: boundDescription }, + { slug: "main/gpt-5.6-sol", priority: 0, multiAgentVersion: "v2", description: boundDescription }, + { slug: "side/gpt-5.6-sol", priority: 0.33, multiAgentVersion: "v2", description: boundDescription }, ]); const effective = effectiveSubagentRoster(["gpt-5.6-sol"], "v2"); expect(effective.advertised.map(model => model.model)).toEqual([ - "personal/gpt-5.6-sol", - "work/gpt-5.6-sol", + "main/gpt-5.6-sol", + "side/gpt-5.6-sol", ]); expect(effective.excluded).toEqual([]); }); + 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 7fea1614f..4b8d0fc7c 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test"; import { accountBoundNativeDisplayName, appendDefaultCodexAccountNamespace, + codexAccountPickerHasVisibleRows, + codexAccountPickerIsEnabled, defaultCodexAccountNamespaces, + visibleCodexAccountNamespaces, } from "../src/codex/account-namespaces"; import { applyNativeVisibility, @@ -38,46 +41,119 @@ function nativeTemplate(): Record { describe("native GPT model toggles (bare slugs in disabledModels)", () => { test("account replacement labels title-case synthesized slug words", () => { - expect(accountBoundNativeDisplayName("work", { slug: "gpt-5.3-codex-spark" })) - .toBe("Work / 5.3 Codex Spark"); + expect(accountBoundNativeDisplayName("side-account", { slug: "gpt-5.3-codex-spark" })) + .toBe("Side-Account / 5.3 Codex Spark"); }); - test("UI defaults generate safe unique account namespaces without exposing email labels", () => { + 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 stable local account ids without exposing aliases or email labels", () => { const namespaces = defaultCodexAccountNamespaces({ providers: { - work: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + "side-id": { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, }, codexAccounts: [ - { id: "work-id", email: "private@example.test", alias: "Work", isMain: false }, + { id: "side-id", email: "private@example.test", alias: "Side", isMain: false }, { id: "team-id", email: "other@example.test", alias: "Product Team", isMain: false }, ], }); - expect(namespaces).toEqual({ personal: "main", "work-2": "work-id", "product-team": "team-id" }); + expect(namespaces).toEqual({ main: "main", "side-id-2": "side-id", "team-id": "team-id" }); expect(JSON.stringify(namespaces)).not.toContain("example.test"); + expect(JSON.stringify(namespaces)).not.toContain("Product Team"); + }); + + 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-id/gpt-5.6-sol", targets: [{ provider: "openai", model: "gpt-5.6-sol" }] }, + }, + codexAccounts: [ + { id: "side-id", email: "side@example.test", isMain: false }, + ], + }); + expect(namespaces).toEqual({ "main-2": "main", "side-id-2": "side-id" }); }); test("new accounts append to an enabled map without renaming existing prefixes", () => { const config = { - providers: { work: { adapter: "openai-chat" as const, baseUrl: "https://example.test/v1" } }, - codexAccountNamespaces: { personal: "main", legacy: "legacy-id" }, + providers: { "side-id": { adapter: "openai-chat" as const, baseUrl: "https://example.test/v1" } }, + codexAccountNamespaces: { main: "main", legacy: "legacy-id" }, }; expect(appendDefaultCodexAccountNamespace(config, { - id: "work-id", - alias: "Work", + id: "side-id", isMain: false, })).toBe(true); expect(config.codexAccountNamespaces).toEqual({ - personal: "main", + main: "main", legacy: "legacy-id", - "work-2": "work-id", + "side-id-2": "side-id", }); expect(appendDefaultCodexAccountNamespace(config, { - id: "work-id", - alias: "Renamed Work", + id: "side-id", 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", isMain: false }, + ], + }); + expect(namespaces).toEqual({ main: "main", side: "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", isMain: false })).toBe(true); + expect(dormant.codexAccountNamespaces).toEqual({ main: "main", side: "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"]); @@ -158,7 +234,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { test("applyNativeVisibility mirrors disabled native state onto account-qualified clones", () => { const entries = [ { - slug: "work/gpt-5.6-sol", + slug: "side/gpt-5.6-sol", description: "OpenAI native model bound to a Codex account namespace.", visibility: "list", }, @@ -171,30 +247,30 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { const entries = buildCatalogEntries( nativeTemplate(), ["gpt-5.5"], - [{ provider: "litellm-personal", id: "qwen3.6" }], + [{ provider: "litellm-local", id: "qwen3.6" }], ["gpt-5.5"], false, "default", new Set(), - { personal: "main", work: "work-account-id" }, + { main: "main", side: "side-account-id" }, ); applyNativeVisibility(entries, new Set(), true); const bare = entries.find(entry => entry.slug === "gpt-5.5"); - const personal = entries.find(entry => entry.slug === "personal/gpt-5.5"); - const work = entries.find(entry => entry.slug === "work/gpt-5.5"); - const routed = entries.find(entry => entry.slug === "litellm-personal/qwen3.6"); + 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(personal).toMatchObject({ - display_name: "Personal / 5.5", + expect(main).toMatchObject({ + display_name: "Main / 5.5", visibility: "list", priority: 0, }); - expect(work?.display_name).toBe("Work / 5.5"); - expect(work?.visibility).toBe("list"); - expect(work?.priority).toBeGreaterThan(personal?.priority as number); - expect(work?.priority).toBe(1); - expect(routed?.priority).toBeGreaterThan(work?.priority as number); + 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); }); @@ -204,7 +280,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { nativeTemplate(), { ...nativeTemplate(), - slug: "work/gpt-5.5", + slug: "side/gpt-5.5", description: "OpenAI native model bound to a Codex account namespace.", }, ], @@ -214,7 +290,7 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { false, ); - expect(merged.some(entry => entry.slug === "work/gpt-5.5")).toBe(false); + expect(merged.some(entry => entry.slug === "side/gpt-5.5")).toBe(false); expect(merged.some(entry => entry.slug === "gpt-5.5")).toBe(true); }); diff --git a/tests/router.test.ts b/tests/router.test.ts index 853cb9239..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", () => { @@ -79,28 +79,36 @@ describe("routeModel registry effort defaults", () => { codexAccountMode: "direct", }, }, - codexAccountNamespaces: { personal: "main", work: "work-account-id" }, + codexAccountNamespaces: { main: "main", side: "side-account-id" }, }; - expect(routeModel(config, "personal/gpt-5.6-sol")).toMatchObject({ + expect(routeModel(config, "main/gpt-5.6-sol")).toMatchObject({ providerName: "openai", modelId: "gpt-5.6-sol", codexAccountMode: "pool", codexAccountId: "__main__", - codexAccountNamespace: "personal", + codexAccountNamespace: "main", }); - expect(routeModel(config, "work/gpt-5.5")).toMatchObject({ + expect(routeModel(config, "side/gpt-5.5")).toMatchObject({ providerName: "openai", modelId: "gpt-5.5", codexAccountMode: "pool", - codexAccountId: "work-account-id", - codexAccountNamespace: "work", + 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", () => { @@ -110,9 +118,28 @@ describe("routeModel registry effort defaults", () => { providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" }, }, - codexAccountNamespaces: { work: "work-account-id" }, + codexAccountNamespaces: { side: "side-account-id" }, }; - expect(() => routeModel(config, "work/claude-opus-4-6")).toThrow("only supports native OpenAI"); + 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", () => { diff --git a/tests/server-auth.test.ts b/tests/server-auth.test.ts index 54d5b9942..94451914c 100644 --- a/tests/server-auth.test.ts +++ b/tests/server-auth.test.ts @@ -310,7 +310,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 = { personal: "main", work: "private-work-account-id" }; + 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 }, @@ -324,7 +324,7 @@ describe("server local API auth", () => { }); const dto = safeConfigDTO(unsafe) as { providers: Record>; - codexAccountNamespacesEnabled: boolean; + codexAccountPickerEnabled: boolean; }; const serialized = JSON.stringify(dto); for (const forbidden of [ @@ -333,9 +333,9 @@ describe("server local API auth", () => { "virtualModels", "codexAuthContext", "selectedForwardHeaders", "sidecarOutcomeRecorder", "recorder-runtime", "_codexAccountOverride", "_codexAccountRequired", "runtime-token", "override-token", - "private-work-account-id", + "private-side-account-id", ]) expect(serialized).not.toContain(forbidden); - expect(dto.codexAccountNamespacesEnabled).toBe(true); + expect(dto.codexAccountPickerEnabled).toBe(true); expect(dto.providers.openai).toMatchObject({ adapter: "openai-chat", baseUrl: "https://api.example.test/v1", @@ -344,6 +344,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(); @@ -1702,10 +1705,10 @@ describe("server local API auth", () => { const body = unsupportedModelBody(); const harness = await startPoolRetryHarness( () => rejectionResponse(body), - { accountNamespaces: { work: "pool-a" }, activeAccountId: "pool-b", accountMode: "direct" }, + { accountNamespaces: { side: "pool-a" }, activeAccountId: "pool-b", accountMode: "direct" }, ); try { - await expectOriginal400(await harness.request({ model: `work/${POOL_RETRY_MODEL}` }), body); + await expectOriginal400(await harness.request({ model: `side/${POOL_RETRY_MODEL}` }), body); expect(harness.dispatches).toEqual(["acct-pool-a"]); expect(harness.config.activeCodexAccountId).toBe("pool-b"); expect(getCodexUpstreamHealth("pool-b")).toBeNull(); @@ -1717,13 +1720,13 @@ describe("server local API auth", () => { test("account-qualified compact request uses the same exact account", async () => { const harness = await startPoolRetryHarness( () => Response.json({ output: [] }), - { accountNamespaces: { work: "pool-a" }, activeAccountId: "pool-b", accountMode: "direct" }, + { 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: `work/${POOL_RETRY_MODEL}`, input: [] }), + body: JSON.stringify({ model: `side/${POOL_RETRY_MODEL}`, input: [] }), }); expect(response.status).toBe(200); expect(harness.dispatches).toEqual(["acct-pool-a"]); @@ -1741,10 +1744,10 @@ describe("server local API auth", () => { upstreamEffort = body.reasoning?.effort; return Response.json({ id: "clamped", status: "completed", output: [] }); }, - { accountNamespaces: { work: "pool-a" }, activeAccountId: "pool-b", accountMode: "direct" }, + { accountNamespaces: { side: "pool-a" }, activeAccountId: "pool-b", accountMode: "direct" }, ); try { - const response = await harness.request({ model: "work/gpt-5.5", reasoningEffort: "max" }); + 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"]); @@ -1761,7 +1764,7 @@ describe("server local API auth", () => { 'data: {"type":"response.completed","response":{"id":"qualified-ws","status":"completed","output":[]}}\n\n', ].join(""), { headers: { "content-type": "text/event-stream" } }), { - accountNamespaces: { work: "pool-a" }, + accountNamespaces: { side: "pool-a" }, activeAccountId: "pool-b", accountMode: "direct", websockets: true, @@ -1785,7 +1788,7 @@ describe("server local API auth", () => { }); ws.send(JSON.stringify({ type: "response.create", - model: "work/gpt-5.5", + model: "side/gpt-5.5", input: "hello", })); await terminal; diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index 2cfa374ee..b52f6afab 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -195,36 +195,80 @@ describe("PUT /api/settings", () => { expect(res!.status).toBe(400); }); - test("account-specific model toggle creates aliases server-side and removes the feature cleanly", async () => { + test("enabling account-specific models avoids combo alias prefixes and survives config reload", async () => { const config: OcxConfig = { ...baseConfig(), codexAccounts: [ - { id: "work-id", email: "work@example.test", alias: "Work", plan: "business", isMain: false }, + { id: "side-id", email: "side@example.test", alias: "Side", plan: "business", isMain: false }, ], + combos: { + primary: { + alias: "main/gpt-5.5", + targets: [{ provider: "openai", model: "gpt-test" }], + }, + secondary: { + alias: "side-id/gpt-5.6-sol", + targets: [{ provider: "openai", model: "gpt-test" }], + }, + }, }; let refreshes = 0; const refresh = async () => { refreshes += 1; }; - const enabled = await putSettings(config, { codexAccountNamespacesEnabled: true }, refresh); + const enabled = await putSettings(config, { codexAccountPickerEnabled: true }, refresh); expect(enabled!.status).toBe(200); - expect(config.codexAccountNamespaces).toEqual({ personal: "main", work: "work-id" }); + expect(config.codexAccountNamespaces).toEqual({ "main-2": "main", "side-id-2": "side-id" }); expect(await enabled!.json()).toMatchObject({ - codexAccountNamespacesEnabled: true, + codexAccountPickerEnabled: true, }); expect(refreshes).toBe(1); + expect(loadConfig()).toMatchObject({ + codexAccountNamespaces: { "main-2": "main", "side-id-2": "side-id" }, + codexAccountPickerEnabled: true, + }); + }); + + 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, { codexAccountNamespacesEnabled: false }, refresh); + const disabled = await putSettings(config, { codexAccountPickerEnabled: false }, refresh); expect(disabled!.status).toBe(200); - expect(config.codexAccountNamespaces).toBeUndefined(); + expect(Object.entries(config.codexAccountNamespaces ?? {})).toEqual(expectedEntries); + expect(config.codexAccountPickerEnabled).toBe(false); expect(await disabled!.json()).toMatchObject({ - codexAccountNamespacesEnabled: false, + 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, { codexAccountNamespacesEnabled: "yes" }))!.status).toBe(400); + expect((await putSettings(config, { codexAccountPickerEnabled: "yes" }))!.status).toBe(400); }); }); From 0af72188f9e32f649e8364c4f83e339a731aed8d Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Sat, 25 Jul 2026 12:14:36 -0400 Subject: [PATCH 13/15] Address final account picker review feedback --- docs-site/src/content/docs/reference/configuration.md | 2 +- .../src/content/docs/zh-cn/guides/codex-app-models.md | 6 +++--- src/server/management/config-routes.ts | 6 ++++-- tests/settings-stream-mode.test.ts | 10 ++++++++++ 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 8bba2b9e0..27a702415 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -52,7 +52,7 @@ 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 picker namespace to exact Codex account id map. Use `"main"` for the current Codex Desktop/main login. When present, account-qualified native rows replace plain GPT rows in the picker and fail closed instead of switching accounts. Plain model ids remain routable. Namespace keys cannot collide with provider ids. | +| `codexAccountNamespaces?` | `Record` | `{}` | Optional picker namespace to exact Codex account id map. Use `"main"` for the current Codex Desktop/main login. When non-empty, account-qualified native rows replace plain GPT rows in the picker and fail closed instead of switching accounts. Plain model ids remain routable. Namespace 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. | 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 2c7dc3929..3a5ba8c3e 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 @@ -23,9 +23,9 @@ side/gpt-5.6-sol # 仅使用本地 ID 为 side 的已添加账 `main` 表示内置登录。已添加账户的前缀由用户指定的本地 ID 生成;如果与现有路由名称冲突,则添加 数字后缀。这个 ID 并不代表账户类型。账户专属模型会绕过 Pool 选择与回退;如果指定账户不可用,请求会直接失败。不带前缀的 `gpt-*` 会保留原有 Pool/Direct -行为。关闭设置只会隐藏这些条目,不会删除已保存的绑定。手动配置与 `ocx sync` 详见 -删除已添加账户后,其选择器条目会消失,但绑定会保留,确保已保存的选择不会转到其他账户。使用相同 -本地 ID 重新添加账户会恢复这些条目。手动配置与 `ocx sync` 详见[配置参考](/zh-cn/reference/configuration/)。 +行为。关闭设置只会隐藏这些条目,不会删除已保存的绑定。删除已添加账户后,其选择器条目会消失, +但绑定会保留,确保已保存的选择不会转到其他账户。使用相同本地 ID 重新添加账户会恢复这些条目。 +手动配置与 `ocx sync` 详见[配置参考](/zh-cn/reference/configuration/)。 ## 集成路径 diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 7fd935d9f..64b41848f 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -191,12 +191,14 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { 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(), From 2ff3e24be707433e2dd715e4e8b6df7247ac0173 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Sat, 25 Jul 2026 12:15:17 -0400 Subject: [PATCH 14/15] Clarify account picker visibility fallback --- docs-site/src/content/docs/reference/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 27a702415..f2257980f 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -52,7 +52,7 @@ 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 picker namespace to exact Codex account id map. Use `"main"` for the current Codex Desktop/main login. When non-empty, account-qualified native rows replace plain GPT rows in the picker and fail closed instead of switching accounts. Plain model ids remain routable. Namespace keys cannot collide with provider ids. | +| `codexAccountNamespaces?` | `Record` | `{}` | Optional picker namespace to exact Codex account id map. Use `"main"` for the current Codex Desktop/main login. 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. Namespace 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. | From 897d26600c338a6d088d59b315804b7d60ff7057 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Sat, 25 Jul 2026 15:58:08 -0400 Subject: [PATCH 15/15] fix(codex): harden account-qualified namespaces --- .../content/docs/guides/codex-app-models.md | 32 +++--- .../docs/ja/guides/codex-app-models.md | 20 ++-- .../docs/ko/guides/codex-app-models.md | 18 ++-- .../content/docs/reference/configuration.md | 32 +++--- .../docs/ru/guides/codex-app-models.md | 14 ++- .../docs/zh-cn/guides/codex-app-models.md | 15 +-- gui/src/components/AddCodexAccountModal.tsx | 10 +- gui/src/components/CodexAccountPool.tsx | 10 +- gui/src/hooks/useCodexAccountPool.ts | 5 +- gui/src/i18n/de.ts | 5 +- gui/src/i18n/en.ts | 5 +- gui/src/i18n/ja.ts | 5 +- gui/src/i18n/ko.ts | 5 +- gui/src/i18n/ru.ts | 5 +- gui/src/i18n/zh.ts | 5 +- gui/src/pages/CodexAuth.tsx | 3 +- gui/src/pages/Providers.tsx | 9 +- .../codex-account-picker-setting.test.tsx | 10 +- .../codex-account-pool-behaviour.test.tsx | 19 ++++ gui/tests/codex-auth-picker-save.test.tsx | 99 +++++++++++++++++++ src/codex/account-label.ts | 2 +- src/codex/account-namespaces.ts | 28 ++++-- src/codex/auth-api.ts | 70 +++++++++---- src/codex/auth-context.ts | 3 + src/codex/catalog/sync.ts | 51 +++++++--- src/server/management/config-routes.ts | 1 + src/server/responses/collaboration.ts | 34 +++++-- src/types.ts | 2 +- tests/codex-auth-api.test.ts | 66 +++++++++++++ tests/codex-auth-context.test.ts | 32 ++++++ tests/codex-auth-modal-status.test.ts | 3 + tests/codex-catalog-sync-hardening.test.ts | 59 +++++++++++ tests/multi-agent-compat.test.ts | 39 +++++++- tests/native-model-toggle.test.ts | 69 ++++++++++--- tests/provider-workspace-auth.test.ts | 14 +++ tests/server-auth.test.ts | 10 +- tests/settings-stream-mode.test.ts | 47 ++++++++- 37 files changed, 706 insertions(+), 150 deletions(-) create mode 100644 gui/tests/codex-auth-picker-save.test.tsx 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 e30fb776d..8244cd11d 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -34,9 +34,11 @@ main/gpt-5.6-sol # exact main/Desktop account side/gpt-5.6-sol # exact added account ``` -`main` identifies the built-in Codex login. Every added account derives its selector prefix from its -user-chosen local OpenCodex ID; a numeric suffix avoids existing provider or combo prefixes. Those -IDs do not represent built-in account types. The equivalent config is: +`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 { @@ -44,17 +46,21 @@ IDs do not represent built-in account types. The equivalent config is: } ``` -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. +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 local ID restores those rows. - -An account-qualified model never switches accounts. 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 +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 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 893d42805..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 @@ -18,16 +18,20 @@ ID は変わりません。API GPT-5.6 は context 1,050,000 / max input 922,000 ```text main/gpt-5.6-sol # 組み込みの Codex ログインに固定 -side/gpt-5.6-sol # ローカル ID が side の追加アカウントに固定 +side/gpt-5.6-sol # 公開セレクター side に対応する追加アカウントに固定 ``` -`main` は組み込みログインを表します。追加アカウントのプレフィックスはユーザーが指定した -ローカル ID から生成され、既存のルーティング名と重なる場合は数字が付きます。この ID は -アカウント種別ではありません。アカウント指定モデルは Pool 選択や -フォールバックを行わず、選択したアカウントが利用できない場合は失敗します。プレフィックスなしの -`gpt-*` は従来の Pool/Direct 動作を維持します。設定を無効にしても項目が非表示になるだけで、 -保存済みのバインドは削除されません。追加アカウントを削除するとその項目は消えますが、保存済みの -選択が別アカウントへ流れないようバインドは残ります。同じローカル ID を再追加すると項目が戻ります。 +`main` は組み込みログインを表します。`side` は保存済みアカウント 1 件に非公開で対応する公開 +セレクターであり、内部アカウント ID やアカウント種別ではありません。ダッシュボードが生成する +セレクターには、ユーザーが明示した表示名を使います。表示名がなければ、安定したプライバシー保護 +ラベル `pXXXXXX` を使い、保存済みアカウント ID やメールアドレスからは生成しません。既存または +カスタムのセレクターは変更されず、名前が重なる場合だけ数字が付きます。アカウント指定の native +セレクターは引き続き canonical `openai` プロバイダーを通り、選択したアカウントに固定されます。 +Pool/Direct のアカウント選択やフォールバックは行わず、選択したアカウントが利用できない場合は +失敗します。プレフィックスなしの `gpt-*` は従来の Pool/Direct 動作を維持します。設定を無効にしても +項目が非表示になるだけで、保存済みのバインドは削除されません。追加アカウントを削除するとその項目は +消えますが、保存済みの選択が別アカウントへ流れないようバインドは残ります。同じ保存済みアカウントを +再追加すると項目が戻ります。 手動設定と `ocx sync` については [設定リファレンス](/ja/reference/configuration/)を参照してください。 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 8f09168d5..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 @@ -18,15 +18,19 @@ id는 변하지 않습니다. API GPT-5.6은 context 1,050,000 / max input 922,0 ```text main/gpt-5.6-sol # 내장 Codex 로그인에 고정 -side/gpt-5.6-sol # 로컬 ID가 side인 추가 계정에 고정 +side/gpt-5.6-sol # 공개 선택기 side에 연결된 추가 계정에 고정 ``` -`main`은 내장 로그인을 뜻합니다. 추가 계정의 접두사는 사용자가 정한 로컬 ID에서 만들어지고 기존 -라우팅 이름과 겹치면 숫자 접미사가 붙습니다. 이 ID는 계정 유형이 아닙니다. -계정 지정 모델은 Pool 선택과 폴백을 건너뛰며, 선택한 계정을 사용할 수 없으면 요청이 실패합니다. -접두사 없는 `gpt-*`는 기존 Pool/Direct 동작을 유지합니다. 설정을 꺼도 항목만 숨겨지고 저장된 바인딩은 -삭제되지 않습니다. 추가 계정을 삭제하면 선택기 항목은 사라지지만 저장된 선택이 다른 계정으로 넘어가지 -않도록 바인딩은 남습니다. 같은 로컬 ID를 다시 추가하면 항목이 복원됩니다. 수동 설정과 `ocx sync`는 +`main`은 내장 로그인을 뜻합니다. `side`는 저장된 계정 하나에 비공개로 연결되는 공개 선택기이며 내부 +계정 ID나 계정 유형이 아닙니다. 대시보드가 선택기를 만들 때는 사용자가 명시한 표시 이름을 사용하고, +표시 이름이 없으면 안정적인 개인정보 보호 라벨 `pXXXXXX`를 사용합니다. 저장된 계정 ID나 이메일에서 +선택기를 만들지 않습니다. 기존 선택기와 사용자 지정 선택기는 바뀌지 않으며 이름이 겹칠 때만 숫자 +접미사가 붙습니다. 계정 지정 native 선택기는 계속 canonical `openai` 프로바이더를 통해 라우팅되고 +정확히 선택한 계정에 고정됩니다. Pool/Direct 계정 선택이나 폴백을 사용하지 않으며, 선택한 계정을 사용할 +수 없으면 요청이 실패합니다. 접두사 없는 `gpt-*`는 기존 Pool/Direct 동작을 유지합니다. 설정을 꺼도 +항목만 숨겨지고 저장된 바인딩은 삭제되지 않습니다. 추가 계정을 삭제하면 선택기 항목은 사라지지만 저장된 +선택이 다른 계정으로 넘어가지 않도록 바인딩은 남습니다. 같은 저장 계정을 다시 추가하면 항목이 복원됩니다. +수동 설정과 `ocx sync`는 [설정 레퍼런스](/ko/reference/configuration/)를 참고하세요. ## 통합 경로 diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 2269b094c..0a2a0b244 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -52,7 +52,7 @@ 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 picker namespace to exact Codex account id map. Use `"main"` for the current Codex Desktop/main login. 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. Namespace keys cannot collide with provider ids. | +| `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. | @@ -92,23 +92,28 @@ Use `codexAccountNamespaces` when account choice must be visible and intentional } ``` -The added account id is the local `ID` shown by `ocx account list openai`; the namespace is a -user-owned picker prefix. 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 prefixes without adding the model separately to this map. +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 and derives every other prefix from the added account's local ID. A numeric suffix resolves -provider or combo-prefix collisions. These names identify accounts; they do not represent built-in -account types. Turning the setting off hides the account-qualified rows while +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. -Upstream ChatGPT account IDs and credentials stay server-side. Local picker prefixes stay stable if -an account's display alias is renamed; accounts added later receive a new prefix automatically while -the feature remains enabled. +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 local account ID restores the existing prefix instead of creating +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 @@ -122,6 +127,9 @@ failure failover, affinity rebinding, and unsupported-model retry. If the exact 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. 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 59d726745..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 @@ -30,16 +30,20 @@ openai-apikey/gpt-5.6-sol # API key ```text main/gpt-5.6-sol # только встроенный вход Codex -side/gpt-5.6-sol # только добавленный аккаунт с локальным ID side +side/gpt-5.6-sol # только добавленный аккаунт с публичным селектором side ``` -`main` обозначает встроенный вход. Префикс добавленного аккаунта создаётся из выбранного -пользователем локального ID; при конфликте с существующим маршрутом добавляется числовой суффикс. -Этот ID не является типом аккаунта. Такая модель не использует выбор или резервирование Pool: если +`main` обозначает встроенный вход. `side` — публичный селектор, который приватно сопоставлен одному +сохранённому аккаунту; это не внутренний ID и не тип аккаунта. Для автоматически созданного селектора +используется явно заданное пользователем отображаемое имя, а без него — стабильная безопасная метка +`pXXXXXX`. Селектор никогда не создаётся из сохранённого ID аккаунта или адреса электронной почты. +Существующие и пользовательские селекторы не переименовываются; при конфликте добавляется числовой +суффикс. Нативный селектор с аккаунтом по-прежнему маршрутизируется через канонический провайдер +`openai`, закрепляется за точным аккаунтом и не использует выбор или резервирование Pool/Direct. Если указанный аккаунт недоступен, запрос завершается ошибкой. ID `gpt-*` без префикса сохраняют обычное поведение Pool/Direct. При отключении настройки строки только скрываются, а сохранённые привязки не удаляются. При удалении добавленного аккаунта его строки исчезают, но привязка остаётся, чтобы -сохранённый выбор не перешёл на другой аккаунт. Повторное добавление того же локального ID +сохранённый выбор не перешёл на другой аккаунт. Повторное добавление того же сохранённого аккаунта восстанавливает строки. Ручная настройка и `ocx sync` описаны в [справочнике конфигурации](/ru/reference/configuration/). 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 3a5ba8c3e..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 @@ -17,14 +17,17 @@ context / 922,000 max input;`*-pro` picker id 保持公开身份,线上使 ```text main/gpt-5.6-sol # 仅使用内置 Codex 登录 -side/gpt-5.6-sol # 仅使用本地 ID 为 side 的已添加账户 +side/gpt-5.6-sol # 仅使用公开选择器为 side 的已添加账户 ``` -`main` 表示内置登录。已添加账户的前缀由用户指定的本地 ID 生成;如果与现有路由名称冲突,则添加 -数字后缀。这个 ID 并不代表账户类型。账户专属模型会绕过 -Pool 选择与回退;如果指定账户不可用,请求会直接失败。不带前缀的 `gpt-*` 会保留原有 Pool/Direct -行为。关闭设置只会隐藏这些条目,不会删除已保存的绑定。删除已添加账户后,其选择器条目会消失, -但绑定会保留,确保已保存的选择不会转到其他账户。使用相同本地 ID 重新添加账户会恢复这些条目。 +`main` 表示内置登录。`side` 是一个公开选择器,会在内部映射到一个已存储账户;它不是内部账户 ID, +也不代表账户类型。由仪表盘生成选择器时,会优先使用用户明确设置的显示名称;如果未设置显示名称, +则使用稳定且保护隐私的 `pXXXXXX` 标签。选择器绝不会从存储的账户 ID 或电子邮件生成。现有选择器和 +自定义选择器不会被重命名;如果与现有路由名称冲突,则添加数字后缀。账户专属的原生选择器仍会通过 +规范的 `openai` provider 路由,并固定使用所选账户,不会转入 Pool/Direct 账户选择或回退。如果指定 +账户不可用,请求会直接失败。不带前缀的 `gpt-*` 会保留原有 Pool/Direct 行为。关闭设置只会隐藏这些 +条目,不会删除已保存的绑定。删除已添加账户后,其选择器条目会消失,但绑定会保留,确保已保存的 +选择不会转到其他账户。重新添加同一已存储账户会恢复这些条目。 手动配置与 `ocx sync` 详见[配置参考](/zh-cn/reference/configuration/)。 ## 集成路径 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/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 9d5375800..aaa041d40 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -610,9 +610,9 @@ export const de = { "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. Die Bezeichnung hinzugefügter Konten wird aus der lokalen ID auf ihrer Karte abgeleitet; der integrierte Login beginnt mit Main. Bei Namenskonflikten wird eine Zahl angehängt. Die Auswahl bindet die Unterhaltung genau an dieses Konto; OpenCodex wechselt nicht und fällt nicht auf ein anderes Konto zurück.", + "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 und gespeicherte Modellauswahlen behalten ihr aktuelles Routing; reine GPT-Modell-IDs behalten ihr Pool- oder Direktverhalten.", + "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", @@ -663,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 62ad3598d..a48f29107 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -910,9 +910,9 @@ export const en = { "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 now gets a separate entry for every GPT model. Added accounts use a picker label derived from the local ID shown on their card; the built-in login starts with Main. A numeric suffix resolves any label collision. Selecting one pins the conversation to that exact account; OpenCodex will not switch or fall back to another account.", + "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 and saved model selections keep their current routing; bare GPT model IDs keep their Pool or Direct behavior.", + "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", @@ -965,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 351ca40ec..11c2c5c9d 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -864,9 +864,9 @@ export const ja: Record = { "codexAuth.openaiDisabled": "組み込みの OpenAI プロバイダーが無効です。", "codexAuth.openProviders": "プロバイダーを開く", "codexAuth.namespacesTitle": "各 Codex アカウントをモデル選択に個別表示", - "codexAuth.namespacesOnDesc": "利用可能な Codex アカウントごとに各 GPT モデルが個別に表示されます。追加アカウントのラベルはカードに表示されたローカル ID から作られ、組み込みログインは Main から始まります。ラベルが重なる場合は数字が付きます。選択した会話はそのアカウントに固定され、OpenCodex が別のアカウントへ切り替えたりフォールバックしたりすることはありません。", + "codexAuth.namespacesOnDesc": "利用可能な Codex アカウントごとに各 GPT モデルが個別に表示されます。Main は組み込みログインを表し、公開される各ピッカーラベルは保存済みアカウント 1 件に非公開で対応します。追加アカウントにはユーザーが指定した表示名を使い、表示名がなければ安定したプライバシー保護ラベル pXXXXXX を使います。保存済みアカウント ID やメールアドレスは使用しません。既存またはカスタムのラベルは変更されません。選択した会話はそのアカウントに固定され、OpenCodex が別のアカウントへ切り替えたりフォールバックしたりすることはありません。", "codexAuth.namespacesOffDesc": "複数の Codex アカウントから、ログアウトせずに会話で使うアカウントを明示的に選びたい場合に有効にします。無効にしてもこれらの項目が非表示になるだけで、アカウントは削除されません。", - "codexAuth.namespacesCompatibilityNote": "モデル選択の通常の GPT 項目を、アカウント別の明示的な項目に置き換えます。既存の会話と保存済みのモデル選択は現在のルーティングを維持し、プレフィックスなしの GPT モデル ID は従来のプールまたは直接動作を維持します。", + "codexAuth.namespacesCompatibilityNote": "モデル選択の通常の GPT 項目を、アカウント別の明示的な項目に置き換えます。既存の会話、カスタムのアカウントラベル、保存済みのモデル選択は現在のルーティングを維持し、プレフィックスなしの GPT モデル ID は従来のプールまたは直接動作を維持します。", "codexAuth.namespacesSaved": "アカウント別のモデル選択項目を更新しました。表示が古い場合は Codex を開き直してください。", "codexAuth.namespacesSaveFailed": "モデル選択を更新できませんでした。以前の設定は維持されます。", "codexAuth.add": "追加", @@ -919,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 892eefa10..6f8a7b094 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -629,9 +629,9 @@ export const ko: Record = { "codexAuth.openaiDisabled": "내장 OpenAI 프로바이더가 비활성화되어 있습니다.", "codexAuth.openProviders": "프로바이더 열기", "codexAuth.namespacesTitle": "각 Codex 계정을 모델 선택기에 별도로 표시", - "codexAuth.namespacesOnDesc": "사용 가능한 Codex 계정마다 각 GPT 모델이 별도 항목으로 표시됩니다. 추가 계정의 라벨은 카드에 표시된 로컬 ID에서 만들어지고 기본 제공 로그인은 Main으로 시작합니다. 라벨이 겹치면 숫자 접미사가 붙습니다. 선택한 대화는 해당 계정에 고정되고 OpenCodex는 다른 계정으로 전환하거나 대체하지 않습니다.", + "codexAuth.namespacesOnDesc": "사용 가능한 Codex 계정마다 각 GPT 모델이 별도 항목으로 표시됩니다. Main은 기본 제공 로그인을 뜻하며 공개 선택기 라벨은 저장된 계정 하나에 비공개로 연결됩니다. 추가 계정은 사용자가 정한 표시 이름을 사용하고, 표시 이름이 없으면 안정적인 개인정보 보호 라벨 pXXXXXX를 사용합니다. 저장된 계정 ID와 이메일은 사용하지 않습니다. 기존 라벨과 사용자 지정 라벨은 바뀌지 않습니다. 선택한 대화는 해당 계정에 고정되고 OpenCodex는 다른 계정으로 전환하거나 대체하지 않습니다.", "codexAuth.namespacesOffDesc": "여러 Codex 계정 중 대화에 사용할 계정을 로그아웃 없이 명시적으로 선택하려면 켜세요. 꺼도 이 항목만 숨겨지며 계정은 삭제되지 않습니다.", - "codexAuth.namespacesCompatibilityNote": "모델 선택기의 일반 GPT 항목을 명시적인 계정별 항목으로 바꿉니다. 기존 대화와 저장된 모델 선택은 현재 라우팅을 유지하고, 접두사 없는 GPT 모델 ID는 기존 풀 또는 직접 동작을 유지합니다.", + "codexAuth.namespacesCompatibilityNote": "모델 선택기의 일반 GPT 항목을 명시적인 계정별 항목으로 바꿉니다. 기존 대화, 사용자 지정 계정 라벨, 저장된 모델 선택은 현재 라우팅을 유지하고, 접두사 없는 GPT 모델 ID는 기존 풀 또는 직접 동작을 유지합니다.", "codexAuth.namespacesSaved": "계정별 모델 선택 항목이 업데이트되었습니다. 목록이 오래된 경우 Codex를 다시 여세요.", "codexAuth.namespacesSaveFailed": "모델 선택기를 업데이트하지 못했습니다. 이전 설정은 유지됩니다.", "codexAuth.add": "추가", @@ -682,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 0802b6a68..b70b48780 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -909,9 +909,9 @@ export const ru: Record = { "codexAuth.openaiDisabled": "Встроенный провайдер OpenAI отключён.", "codexAuth.openProviders": "Открыть провайдеров", "codexAuth.namespacesTitle": "Показывать каждый аккаунт Codex отдельно в списке моделей", - "codexAuth.namespacesOnDesc": "Для каждого доступного аккаунта Codex каждая GPT-модель показывается отдельно. Метка добавленного аккаунта создаётся из локального ID на его карточке, а метка встроенного входа начинается с Main. При совпадении добавляется числовой суффикс. Выбранный диалог закрепляется за этим аккаунтом; OpenCodex не переключается и не использует другой аккаунт как резервный.", + "codexAuth.namespacesOnDesc": "Для каждого доступного аккаунта Codex каждая GPT-модель показывается отдельно. Main обозначает встроенный вход, а каждая публичная метка в селекторе приватно сопоставляется одному сохранённому аккаунту. Для добавленного аккаунта используется заданное пользователем отображаемое имя, а без него — стабильная безопасная метка вида pXXXXXX. Сохранённые ID аккаунтов и адреса электронной почты не используются. Существующие и пользовательские метки не изменяются. Выбранный диалог закрепляется за этим аккаунтом; OpenCodex не переключается и не использует другой аккаунт как резервный.", "codexAuth.namespacesOffDesc": "Включите, если у вас несколько аккаунтов Codex и вы хотите явно выбрать аккаунт для диалога без выхода из системы. При отключении эти строки только скрываются; аккаунты не удаляются.", - "codexAuth.namespacesCompatibilityNote": "Обычные строки GPT в списке моделей заменяются явными вариантами для каждого аккаунта. Существующие диалоги и сохранённый выбор модели сохраняют текущее направление, а ID GPT без префикса — режим Пул или Прямой.", + "codexAuth.namespacesCompatibilityNote": "Обычные строки GPT в списке моделей заменяются явными вариантами для каждого аккаунта. Существующие диалоги, пользовательские метки аккаунтов и сохранённый выбор модели сохраняют текущее направление, а ID GPT без префикса — режим Пул или Прямой.", "codexAuth.namespacesSaved": "Отображение аккаунтов в списке моделей обновлено. Если список устарел, откройте Codex заново.", "codexAuth.namespacesSaveFailed": "Не удалось обновить список моделей. Предыдущая настройка сохранена.", "codexAuth.add": "Добавить", @@ -964,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 5c864dd0f..e0340d9e1 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -629,9 +629,9 @@ export const zh: Record = { "codexAuth.openaiDisabled": "内置 OpenAI 提供方已禁用。", "codexAuth.openProviders": "打开提供商", "codexAuth.namespacesTitle": "在模型选择器中分别显示每个 Codex 账户", - "codexAuth.namespacesOnDesc": "每个可用的 Codex 账户都会单独显示一份各 GPT 模型。已添加账户的标签由其卡片上显示的本地 ID 生成,内置登录的标签以 Main 开头;如有重名则添加数字后缀。选择后,对话会固定使用该账户;OpenCodex 不会切换或回退到其他账户。", + "codexAuth.namespacesOnDesc": "每个可用的 Codex 账户都会单独显示一份各 GPT 模型。Main 表示内置登录;每个公开的选择器标签都会在内部映射到一个已存储账户。已添加账户会使用用户指定的显示名称;如果未设置显示名称,则使用稳定且保护隐私的 pXXXXXX 标签。存储的账户 ID 和电子邮件不会被用作标签。现有标签和自定义标签保持不变。选择后,对话会固定使用该账户;OpenCodex 不会切换或回退到其他账户。", "codexAuth.namespacesOffDesc": "如果你有多个 Codex 账户,并希望无需退出登录即可明确选择对话使用的账户,请开启此项。关闭后只会隐藏这些选项,不会删除任何账户。", - "codexAuth.namespacesCompatibilityNote": "这会用明确的账户专属选项替换模型选择器中的常规 GPT 项。现有对话和已保存的模型选择会保持当前路由,不带前缀的 GPT 模型 ID 会继续遵循原有的账户池或直接模式。", + "codexAuth.namespacesCompatibilityNote": "这会用明确的账户专属选项替换模型选择器中的常规 GPT 项。现有对话、自定义账户标签和已保存的模型选择会保持当前路由,不带前缀的 GPT 模型 ID 会继续遵循原有的账户池或直接模式。", "codexAuth.namespacesSaved": "模型选择器中的账户显示已更新。如果列表仍旧,请重新打开 Codex。", "codexAuth.namespacesSaveFailed": "无法更新模型选择器。之前的设置保持不变。", "codexAuth.add": "添加", @@ -682,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 0874c0b3f..524ab9eda 100644 --- a/gui/src/pages/CodexAuth.tsx +++ b/gui/src/pages/CodexAuth.tsx @@ -38,7 +38,8 @@ export default function CodexAuth({ apiBase }: { apiBase: string }) { }, [loadMode]); const saveNamespacesEnabled = async (next: boolean) => { - if (namespaceSaving || namespacesEnabled === null || namespacesEnabled === next) return; + if (namespaceMutationInFlightRef.current || namespaceSaving + || namespacesEnabled === null || namespacesEnabled === next) return; const previousEnabled = namespacesEnabled; namespaceMutationInFlightRef.current = true; setNamespaceSaving(true); 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 index e6efb1138..6162322ff 100644 --- a/gui/tests/codex-account-picker-setting.test.tsx +++ b/gui/tests/codex-account-picker-setting.test.tsx @@ -44,12 +44,16 @@ describe("Codex account picker setting", () => { test("explains strict binding and compatibility when enabled", () => { const html = renderSetting(true); - expect(html).toContain("picker label derived from the local ID shown on their card"); - expect(html).toContain("the built-in login starts with Main"); - expect(html).toContain("numeric suffix resolves any label collision"); + 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"'); 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-namespaces.ts b/src/codex/account-namespaces.ts index dbdd1c34b..cc87cfbc6 100644 --- a/src/codex/account-namespaces.ts +++ b/src/codex/account-namespaces.ts @@ -1,4 +1,9 @@ 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. */ @@ -33,8 +38,8 @@ export function codexAccountPickerHasVisibleRows( return Object.keys(visibleCodexAccountNamespaces(config)).length > 0; } -function generatedNamespace(localId: string): string { - const normalized = localId.trim().toLowerCase() +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"; @@ -48,7 +53,18 @@ function comboAliasNamespaces(config: Pick): string[] { }); } -/** Build the initial UI-managed map from local ids without exposing upstream account identity. */ +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 { @@ -70,7 +86,7 @@ export function defaultCodexAccountNamespaces( namespaces[claim("main")] = MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET; for (const account of config.codexAccounts ?? []) { if (account.isMain || isMainCodexAccountTarget(account.id)) continue; - namespaces[claim(account.id)] = account.id; + namespaces[claim(defaultPublicAccountLabel(account))] = account.id; } return namespaces; } @@ -78,7 +94,7 @@ export function defaultCodexAccountNamespaces( /** Add a newly stored account to an existing UI-managed map without renaming old rows. */ export function appendDefaultCodexAccountNamespace( config: Pick, - account: { id: string; isMain: boolean }, + account: { id: string; alias?: string; logLabel?: string; isMain: boolean }, ): boolean { if (account.isMain || isMainCodexAccountTarget(account.id) @@ -91,7 +107,7 @@ export function appendDefaultCodexAccountNamespace( ...Object.keys(config.codexAccountNamespaces), ...RESERVED_NAMESPACE_KEYS, ]); - const base = generatedNamespace(account.id); + const base = generatedNamespace(defaultPublicAccountLabel(account)); let namespace = base; let suffix = 2; while (used.has(namespace)) namespace = `${base}-${suffix++}`; diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index b7becdd6d..3d5eb853f 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -9,13 +9,13 @@ import { CodexCredentialRefreshLockTimeoutError, 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"; +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"; import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; @@ -64,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; @@ -467,14 +474,22 @@ export async function listCodexAuthAccounts(config: OcxConfig, forceRefresh = fa return [main, ...withQuota]; } -async function refreshAccountNamespaceCatalog(config: OcxConfig, changed: boolean): Promise { - if (!changed || !codexAccountPickerIsEnabled(config)) return; - try { - const { refreshCodexModelCatalog } = await import("./refresh"); - await refreshCodexModelCatalog(config); - } catch { - // Account persistence succeeds even when no Codex catalog has been injected yet. +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( @@ -536,8 +551,11 @@ export async function handleCodexAuthAPI( runtimeConfig.codexAccounts = accounts; const namespaceAdded = appendDefaultCodexAccountNamespace(runtimeConfig, addedAccount); saveRuntimeConfig(config, runtimeConfig); - await refreshAccountNamespaceCatalog(runtimeConfig, namespaceAdded || retainedPickerBindingRestored); - 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") { @@ -546,8 +564,8 @@ export async function handleCodexAuthAPI( const runtimeConfig = getRuntimeConfig(config); const pickerChanged = deleteCodexAccount(runtimeConfig, id); saveRuntimeConfig(config, runtimeConfig); - await refreshAccountNamespaceCatalog(runtimeConfig, pickerChanged); - 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") { @@ -842,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({ @@ -860,9 +879,18 @@ export async function handleCodexAuthAPI( latestConfig.codexAccounts = accounts; const namespaceAdded = appendDefaultCodexAccountNamespace(latestConfig, addedAccount); saveRuntimeConfig(config, latestConfig); - await refreshAccountNamespaceCatalog(latestConfig, namespaceAdded || retainedPickerBindingRestored); + 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 e7dc477cc..4a88a66bd 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -178,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); } diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index f5ef6cbb9..ae6e0cf13 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -388,6 +388,7 @@ export function mergeCatalogEntriesForSync( 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 @@ -446,14 +447,15 @@ 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("/") - && accountBoundNativeCatalogSlug(m) === undefined - ); + finalRoutedEntries = existingRoutedEntries; } else { const preservedForeignRouted = catalogModels.filter(m => { if (typeof m.slug !== "string" || !m.slug.includes("/")) return false; @@ -480,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 @@ -564,7 +576,7 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num exactComboSlugs, visibleCodexAccountNamespaces(config), ).filter(entry => accountBoundNativeCatalogSlug(entry) !== undefined); - const goEntries = [...routedEntries, ...accountBoundEntries]; + 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. @@ -580,11 +592,28 @@ export async function syncCatalogModels(config: OcxConfig): Promise<{ added: num // 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, codexAccountPickerHasVisibleRows(config)); + // 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/server/management/config-routes.ts b/src/server/management/config-routes.ts index 64b41848f..06791b543 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -119,6 +119,7 @@ export async function handleConfigRoutes(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/types.ts b/src/types.ts index c27c7cdb9..b705c6d11 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1002,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/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index a247f6f44..f8aca3079 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -737,6 +737,72 @@ 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(); diff --git a/tests/codex-auth-context.test.ts b/tests/codex-auth-context.test.ts index 41af22045..1bdafddbe 100644 --- a/tests/codex-auth-context.test.ts +++ b/tests/codex-auth-context.test.ts @@ -263,6 +263,38 @@ describe("Codex auth context", () => { 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/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index c65d9e92a..d04f269ae 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -158,13 +158,14 @@ describe("multiAgentGuidanceText", () => { } }); - test("bare subagent choices project onto visible account-qualified replacements", () => { + 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: "main/gpt-5.6-sol", priority: 0, multiAgentVersion: "v2", description: boundDescription }, - { slug: "side/gpt-5.6-sol", priority: 0.33, multiAgentVersion: "v2", description: boundDescription }, + { 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"); @@ -173,6 +174,38 @@ describe("multiAgentGuidanceText", () => { "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", () => { diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index 4b8d0fc7c..b8f59e686 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -1,4 +1,8 @@ import { describe, expect, test } from "bun:test"; +import { + CODEX_ACCOUNT_LOG_LABEL_RE, + fallbackCodexAccountLogLabel, +} from "../src/codex/account-label"; import { accountBoundNativeDisplayName, appendDefaultCodexAccountNamespace, @@ -57,19 +61,46 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { ]); }); - test("UI defaults use stable local account ids without exposing aliases or email labels", () => { + test("UI defaults use public aliases or privacy-safe labels without exposing stored account ids", () => { const namespaces = defaultCodexAccountNamespaces({ providers: { - "side-id": { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, + side: { adapter: "openai-chat", baseUrl: "https://example.test/v1" }, }, codexAccounts: [ - { id: "side-id", email: "private@example.test", alias: "Side", isMain: false }, - { id: "team-id", email: "other@example.test", alias: "Product Team", isMain: false }, + { 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 }, ], }); - expect(namespaces).toEqual({ main: "main", "side-id-2": "side-id", "team-id": "team-id" }); - expect(JSON.stringify(namespaces)).not.toContain("example.test"); - expect(JSON.stringify(namespaces)).not.toContain("Product Team"); + 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", () => { @@ -77,13 +108,13 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { providers: {}, combos: { primary: { alias: "main/gpt-5.5", targets: [{ provider: "openai", model: "gpt-5.5" }] }, - secondary: { alias: "side-id/gpt-5.6-sol", targets: [{ provider: "openai", model: "gpt-5.6-sol" }] }, + secondary: { alias: "side/gpt-5.6-sol", targets: [{ provider: "openai", model: "gpt-5.6-sol" }] }, }, codexAccounts: [ - { id: "side-id", email: "side@example.test", isMain: false }, + { id: "private-side-id", email: "side@example.test", alias: "Side", logLabel: "p111111", isMain: false }, ], }); - expect(namespaces).toEqual({ "main-2": "main", "side-id-2": "side-id" }); + expect(namespaces).toEqual({ "main-2": "main", "side-2": "private-side-id" }); }); test("new accounts append to an enabled map without renaming existing prefixes", () => { @@ -93,15 +124,19 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { }; expect(appendDefaultCodexAccountNamespace(config, { id: "side-id", + alias: "Side", + logLabel: "p111111", isMain: false, })).toBe(true); expect(config.codexAccountNamespaces).toEqual({ main: "main", legacy: "legacy-id", - "side-id-2": "side-id", + side: "side-id", }); expect(appendDefaultCodexAccountNamespace(config, { id: "side-id", + alias: "Side", + logLabel: "p111111", isMain: false, })).toBe(false); }); @@ -116,10 +151,10 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { 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", isMain: false }, + { id: "side", email: "side@example.test", logLabel: "p333333", isMain: false }, ], }); - expect(namespaces).toEqual({ main: "main", side: "side" }); + expect(namespaces).toEqual({ main: "main", p333333: "side" }); }); test("picker visibility defaults on for legacy maps and preserves dormant mappings", () => { @@ -134,8 +169,12 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { }; expect(codexAccountPickerIsEnabled(dormant)).toBe(false); expect(visibleCodexAccountNamespaces(dormant)).toEqual({}); - expect(appendDefaultCodexAccountNamespace(dormant, { id: "side", isMain: false })).toBe(true); - expect(dormant.codexAccountNamespaces).toEqual({ main: "main", side: "side" }); + expect(appendDefaultCodexAccountNamespace(dormant, { + id: "side", + logLabel: "p444444", + isMain: false, + })).toBe(true); + expect(dormant.codexAccountNamespaces).toEqual({ main: "main", p444444: "side" }); expect(codexAccountPickerIsEnabled(dormant)).toBe(false); }); 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/server-auth.test.ts b/tests/server-auth.test.ts index 94451914c..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"; @@ -1703,17 +1704,22 @@ 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: { side: "pool-a" }, activeAccountId: "pool-b", accountMode: "direct" }, + { accountNamespaces: { "public-selector": "pool-a" }, activeAccountId: "pool-b", accountMode: "direct" }, ); try { - await expectOriginal400(await harness.request({ model: `side/${POOL_RETRY_MODEL}` }), body); + 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(); } }); diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index 79071e196..df6adf527 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -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 { @@ -209,7 +229,7 @@ describe("PUT /api/settings", () => { const config: OcxConfig = { ...baseConfig(), codexAccounts: [ - { id: "side-id", email: "side@example.test", alias: "Side", plan: "business", isMain: false }, + { id: "side-id", email: "side@example.test", alias: "Side", logLabel: "p111111", plan: "business", isMain: false }, ], combos: { primary: { @@ -217,7 +237,7 @@ describe("PUT /api/settings", () => { targets: [{ provider: "openai", model: "gpt-test" }], }, secondary: { - alias: "side-id/gpt-5.6-sol", + alias: "side/gpt-5.6-sol", targets: [{ provider: "openai", model: "gpt-test" }], }, }, @@ -227,15 +247,34 @@ describe("PUT /api/settings", () => { const enabled = await putSettings(config, { codexAccountPickerEnabled: true }, refresh); expect(enabled!.status).toBe(200); - expect(config.codexAccountNamespaces).toEqual({ "main-2": "main", "side-id-2": "side-id" }); + 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-id-2": "side-id" }, + 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 () => {