From 8ca9cab06f444afa05fe8cd1a123b8c6366a3751 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 13:43:30 -0400 Subject: [PATCH 01/19] feat(codex): add account picker lifecycle settings --- src/codex/account-lifecycle.ts | 6 +- src/codex/account-namespaces.ts | 30 +++++ src/codex/auth-api.ts | 71 ++++++++++- src/codex/catalog-refresh-status.ts | 23 ++++ src/codex/catalog/account-models.ts | 17 +-- src/config.ts | 1 + src/server/management-api.ts | 22 +++- src/server/management/config-routes.ts | 80 +++++++++++- src/server/management/context.ts | 6 + src/types.ts | 5 + structure/02_config-and-codex-home.md | 2 +- structure/03_catalog-and-subagents.md | 2 + structure/05_gui-and-management-api.md | 4 +- tests/codex-account-namespaces.test.ts | 62 +++++++++ tests/codex-auth-api.test.ts | 152 +++++++++++++++++++++- tests/config.test.ts | 21 ++- tests/native-model-toggle.test.ts | 22 ++++ tests/router.test.ts | 8 ++ tests/settings-stream-mode.test.ts | 170 ++++++++++++++++++++++++- 19 files changed, 664 insertions(+), 40 deletions(-) create mode 100644 src/codex/catalog-refresh-status.ts diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 1d9ecee29..e9f42fd90 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -7,6 +7,7 @@ import { clearCodexUpstreamHealthForAccount, clearThreadAccountMapForAccount } f import { invalidateCodexWebSocketsForAccount } from "./websocket-registry"; import { clearMainAccountCredentialPresence, clearMainAccountInfoCache } from "./main-account-cache"; import { forgetCodexAccountPause } from "./account-pause"; +import { visibleCodexAccountNamespaceEntries } from "./account-namespaces"; import type { OcxConfig } from "../types"; let observedMainChatgptAccountId: string | undefined; @@ -69,7 +70,9 @@ export function resetMainCodexAccountIdentityTrackingForTests(): void { clearMainAccountCredentialPresence(); } -export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): void { +export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): boolean { + const hadVisiblePickerBinding = visibleCodexAccountNamespaceEntries(runtimeConfig) + .some(([, boundAccountId]) => boundAccountId === accountId); removeCodexAccountCredential(accountId); runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []) .filter(account => account.isMain || account.id !== accountId); @@ -77,4 +80,5 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); + return hadVisiblePickerBinding; } diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts index 6c41850b5..2677fc2ee 100644 --- a/src/codex/account-namespaces.ts +++ b/src/codex/account-namespaces.ts @@ -131,6 +131,17 @@ export function appendDefaultCodexAccountNamespace( return true; } +/** + * Whether generated account-qualified rows are enabled for catalog discovery. + * A non-empty hand-written map predating the explicit override remains enabled. + */ +export function codexAccountPickerIsEnabled( + config: Pick, +): boolean { + return config.codexAccountPickerEnabled !== false + && Object.keys(config.codexAccountNamespaces ?? {}).length > 0; +} + export function isMainCodexAccountTarget(accountId: string): boolean { return accountId === MAIN_CODEX_ACCOUNT_NAMESPACE_TARGET || accountId === MAIN_CODEX_ACCOUNT_ID; } @@ -147,3 +158,22 @@ export function codexAccountNamespaceEntries( return Object.entries(config.codexAccountNamespaces ?? {}) .map(([namespace, accountId]) => [namespace, normalizeCodexAccountNamespaceTarget(accountId)]); } + +/** + * Picker-visible selector bindings. Missing account targets stay configured for exact routing to + * fail closed, but are not advertised. Only public selector keys should leave this boundary. + */ +export function visibleCodexAccountNamespaceEntries( + config: Pick, +): Array<[string, string]> { + if (!codexAccountPickerIsEnabled(config)) return []; + const storedPoolAccounts = new Set( + (config.codexAccounts ?? []) + .filter(account => !account.isMain) + .map(account => account.id), + ); + return codexAccountNamespaceEntries(config) + .filter(([, accountId]) => + isMainCodexAccountTarget(accountId) || storedPoolAccounts.has(accountId) + ); +} diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 3bb657729..67ff7cdf2 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -14,6 +14,11 @@ import { TokenRefreshError, } from "./account-store"; import { deleteCodexAccount, reconcileMainCodexAccountRuntimeState } from "./account-lifecycle"; +import { + appendDefaultCodexAccountNamespace, + codexAccountPickerIsEnabled, +} from "./account-namespaces"; +import { refreshCodexCatalogWithRetry } from "./catalog-refresh-status"; import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause"; import { claimDueCodexQuotaRecoveryProbes, @@ -121,7 +126,15 @@ const MANUAL_IMPORT_ENV = "OPENCODEX_ENABLE_UNVERIFIED_CODEX_IMPORT"; const MAX_CODEX_LOGIN_STATE_ROWS = 32; const CODEX_LOGIN_TERMINAL_TTL_MS = 300_000; -interface CodexLoginStateRow { status: string; startedAt: number; accountId?: string; email?: string; error?: string; doneAt?: number } +interface CodexLoginStateRow { + status: string; + startedAt: number; + accountId?: string; + email?: string; + error?: string; + catalogRefreshPending?: boolean; + doneAt?: number; +} const codexAuthLoginState = new Map(); export class CodexLoginStateBusyError extends ResourceAdmissionError { constructor() { super("codex_login_state_rows", MAX_CODEX_LOGIN_STATE_ROWS); this.name = "CodexLoginStateBusyError"; } @@ -376,6 +389,14 @@ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void Object.assign(sourceConfig, nextConfig); } +async function refreshAccountNamespaceCatalog(config: OcxConfig, changed: boolean): Promise { + if (!changed || !codexAccountPickerIsEnabled(config)) return false; + return refreshCodexCatalogWithRetry(async () => { + const { refreshCodexModelCatalog } = await import("./refresh"); + await refreshCodexModelCatalog(config); + }); +} + async function mapWithConcurrency( items: T[], concurrency: number, @@ -1229,11 +1250,23 @@ export async function handleCodexAuthAPI( markCodexAccountValidated(body.id, warmup.validatedAt); clearAccountNeedsReauth(body.id); const accounts = latestConfig.codexAccounts ?? []; - accounts.push(withCodexAccountLogLabel({ id: body.id, email: body.email, plan: body.plan, isMain: false }, accounts)); + const addedAccount = withCodexAccountLogLabel( + { id: body.id, email: body.email, plan: body.plan, isMain: false }, + accounts, + ); + const retainedPickerBindingRestored = codexAccountPickerIsEnabled(latestConfig) + && Object.values(latestConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id); + accounts.push(addedAccount); latestConfig.codexAccounts = accounts; + const namespaceAdded = latestConfig.codexAccountPickerEnabled !== undefined + && appendDefaultCodexAccountNamespace(latestConfig, addedAccount); saveRuntimeConfig(config, latestConfig); reconcileLiveStateStores(); - return jsonResponse({ ok: true }); + const catalogRefreshPending = await refreshAccountNamespaceCatalog( + latestConfig, + namespaceAdded || retainedPickerBindingRestored, + ); + return jsonResponse({ ok: true, catalogRefreshPending }); } if (url.pathname === "/api/codex-auth/accounts" && req.method === "DELETE") { @@ -1245,10 +1278,14 @@ export async function handleCodexAuthAPI( if (!isValidCodexAccountId(id) && !isLegacyPoolAccount) { return jsonResponse({ error: "Invalid account id format" }, 400); } - deleteCodexAccount(runtimeConfig, id); + const pickerVisibilityChanged = deleteCodexAccount(runtimeConfig, id); saveRuntimeConfig(config, runtimeConfig); reconcileLiveStateStores(); - return jsonResponse({ ok: true }); + const catalogRefreshPending = await refreshAccountNamespaceCatalog( + runtimeConfig, + pickerVisibilityChanged, + ); + return jsonResponse({ ok: true, catalogRefreshPending }); } if (url.pathname === "/api/codex-auth/accounts/alias" && req.method === "PUT") { @@ -1678,6 +1715,7 @@ export async function handleCodexAuthAPI( const latestConfig = getRuntimeConfig(config); const accounts = latestConfig.codexAccounts ?? []; const existingIdx = accounts.findIndex(account => account.id === accountId); + let pickerVisibilityChanged = false; const commitConflict = codexAccountPersistenceConflict( latestConfig, accountId, @@ -1719,12 +1757,31 @@ export async function handleCodexAuthAPI( latestConfig.codexAccounts = accounts; saveRuntimeConfig(config, latestConfig); } else { - accounts.push(withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts)); + const addedAccount = withCodexAccountLogLabel( + { id: accountId, email, plan, isMain: false }, + accounts, + ); + const retainedPickerBindingRestored = codexAccountPickerIsEnabled(latestConfig) + && Object.values(latestConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id); + accounts.push(addedAccount); latestConfig.codexAccounts = accounts; + const namespaceAdded = latestConfig.codexAccountPickerEnabled !== undefined + && appendDefaultCodexAccountNamespace(latestConfig, addedAccount); saveRuntimeConfig(config, latestConfig); + pickerVisibilityChanged = namespaceAdded || retainedPickerBindingRestored; } reconcileLiveStateStores(); - setCodexLoginState(flowId, { status: "done", accountId, email, doneAt: Date.now() }); + const catalogRefreshPending = await refreshAccountNamespaceCatalog( + latestConfig, + pickerVisibilityChanged, + ); + setCodexLoginState(flowId, { + status: "done", + accountId, + email, + ...(catalogRefreshPending ? { catalogRefreshPending: true } : {}), + doneAt: Date.now(), + }); completed = true; } break; diff --git a/src/codex/catalog-refresh-status.ts b/src/codex/catalog-refresh-status.ts new file mode 100644 index 000000000..7afd7e2e9 --- /dev/null +++ b/src/codex/catalog-refresh-status.ts @@ -0,0 +1,23 @@ +/** + * Refresh the Codex catalog, retrying once after a failure. + * + * Returns true only when both attempts fail. The mutation that requested the + * refresh has already been persisted, so callers can report a recoverable + * pending state instead of rolling back durable configuration. + */ +export async function refreshCodexCatalogWithRetry( + refresh: () => Promise, +): Promise { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + await refresh(); + return false; + } catch { + // Retry once. Failure details may contain provider or filesystem data, so + // the terminal warning below stays generic. + } + } + + console.warn("[opencodex] Codex catalog refresh is pending; run `ocx sync` to retry."); + return true; +} diff --git a/src/codex/catalog/account-models.ts b/src/codex/catalog/account-models.ts index 300000293..df0a22ff8 100644 --- a/src/codex/catalog/account-models.ts +++ b/src/codex/catalog/account-models.ts @@ -1,5 +1,5 @@ import type { OcxConfig } from "../../types"; -import { isMainCodexAccountTarget } from "../account-namespaces"; +import { visibleCodexAccountNamespaceEntries } from "../account-namespaces"; import type { RawEntry } from "./parsing"; /** Stable nonsemantic marker used to distinguish generated rows from provider-owned rows. */ @@ -14,18 +14,9 @@ export const CODEX_ACCOUNT_BOUND_CATALOG_KIND = "account-selector-v1"; * namespace validation. Only those public keys leave this boundary; private account ids do not. */ export function visibleCodexAccountSelectors( - config: Pick, + config: Pick, ): string[] { - const storedPoolAccounts = new Set( - (config.codexAccounts ?? []) - .filter(account => !account.isMain) - .map(account => account.id), - ); - return Object.entries(config.codexAccountNamespaces ?? {}) - .filter(([, accountId]) => - isMainCodexAccountTarget(accountId) || storedPoolAccounts.has(accountId) - ) - .map(([selector]) => selector); + return visibleCodexAccountNamespaceEntries(config).map(([selector]) => selector); } export function accountBoundNativeDisplayName(selector: string, native: RawEntry): string { @@ -53,7 +44,7 @@ export function trustedAccountBoundNativeCatalogSlug(entry: RawEntry): string | } export function accountBoundNativeModelSlugs( - config: Pick, + config: Pick, nativeSlugs: Iterable, ): string[] { const natives = [...nativeSlugs]; diff --git a/src/config.ts b/src/config.ts index 72defeb9c..fe05230be 100644 --- a/src/config.ts +++ b/src/config.ts @@ -979,6 +979,7 @@ const configSchema = z.object({ codexShimAutoRestore: z.boolean().optional(), pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(), codexAccountNamespaces: codexAccountNamespacesSchema.optional(), + codexAccountPickerEnabled: z.boolean().optional(), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 3faed8f93..9323f23d6 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -102,11 +102,18 @@ export async function handleManagementAPI( return jsonResponse({ error: "request body too large" }, 413, req, config); } } + async function refreshCodexCatalogStrict(): Promise { + if (deps.refreshCodexCatalog) return deps.refreshCodexCatalog(); + const { refreshCodexModelCatalog } = await import("../codex/refresh"); + await refreshCodexModelCatalog(config); + } + async function refreshCodexCatalogBestEffort(): Promise { + // Preserve the dependency seam's historical behavior: injected failures + // remain observable to route tests, while production discovery is best-effort. if (deps.refreshCodexCatalog) return deps.refreshCodexCatalog(); try { - const { refreshCodexModelCatalog } = await import("../codex/refresh"); - await refreshCodexModelCatalog(config); + await refreshCodexCatalogStrict(); } catch { /* catalog absent */ } @@ -133,7 +140,16 @@ export async function handleManagementAPI( } } catch { /* best-effort */ } } - const ctx: ManagementContext = { req, url, config, deps, principal, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort }; + const ctx: ManagementContext = { + req, + url, + config, + deps, + principal, + refreshCodexCatalogStrict, + refreshCodexCatalogBestEffort, + syncClaudeAgentDefsBestEffort, + }; let routed: Response | null; try { routed = (await handleConfigRoutes(ctx)) diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index cc9802c52..98b5c607f 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -40,6 +40,11 @@ import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../provid import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { clearThreadAccountMap } from "../../codex/routing"; import { primeCodexPoolQuotas } from "../../codex/auth-api"; +import { + codexAccountPickerIsEnabled, + defaultCodexAccountNamespaces, +} from "../../codex/account-namespaces"; +import { refreshCodexCatalogWithRetry } from "../../codex/catalog-refresh-status"; import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap"; import { resolveCodexHomeDir } from "../../codex/home"; import { readUsageEntries } from "../../usage/log"; @@ -74,7 +79,15 @@ import type { ManagementContext } from "./context"; import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body"; export async function handleConfigRoutes(ctx: ManagementContext): Promise { - const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx; + const { + req, + url, + config, + deps, + refreshCodexCatalogStrict, + refreshCodexCatalogBestEffort, + syncClaudeAgentDefsBestEffort, + } = ctx; if (url.pathname === "/api/config" && req.method === "GET") { return jsonResponse(safeConfigDTO(config)); } @@ -129,6 +142,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise Promise; refreshCodexCatalogBestEffort: () => Promise; syncClaudeAgentDefsBestEffort: () => Promise; } diff --git a/src/types.ts b/src/types.ts index ae84aa674..6833c8479 100644 --- a/src/types.ts +++ b/src/types.ts @@ -747,6 +747,11 @@ export interface OcxConfig { * are intentionally separate from these selectors. */ codexAccountNamespaces?: Record; + /** + * Picker visibility override for account-qualified native models. When omitted, a non-empty + * selector map remains visible for compatibility with 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/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 9610fdbb7..20e891080 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -78,7 +78,7 @@ matters for maintainers is which groups exist and who resolves them: | --- | --- | --- | | Listener | `port`, `hostname` | The listener owns the port; `runtime-port.json` reports where it actually landed. | | Routing | `defaultProvider`, `providers`, per-provider `selectedModels` | Explicit `provider/model` wins over `defaultProvider`. | -| Catalog | `disabledModels`, `customModels`, `modelCacheTtlMs`, `providerContextCaps`, `contextCapValue` | Catalog state is derived; config only records intent. | +| Catalog | `disabledModels`, `customModels`, `modelCacheTtlMs`, `providerContextCaps`, `contextCapValue`, `codexAccountPickerEnabled` | Catalog state is derived; config only records intent. The account-picker flag controls discovery only; exact selector bindings remain routable. | | Retained state | `appOwnedMemoryBudgetMb` | Process-wide eviction target for app-owned logs, caches, blobs, and continuation payloads. Default 256 MiB, valid 64..4096; pinned state may temporarily exceed the target, but every pin-capable store has a finite local cap and their documented aggregate stays below `APP_OWNED_WORST_CASE_PINNED_BYTES` (512 MiB). Neither value caps RSS or native runtime memory. | | Transport | stream mode, timeouts, proxy settings, `websockets` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. | | Credentials | `apiKeys` | Data-plane only; never admitted to `/api/*`. | diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index ea34fa164..b2d212dda 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -69,6 +69,8 @@ Pool mode routes across main plus added Codex credentials. Key rules: the Codex catalog clones each supported native row per selector and hides the bare picker rows; bare ids remain routable and stay in raw `/v1/models` unless explicitly disabled. Missing stored account targets are not advertised, and private account ids never become catalog labels. + `codexAccountPickerEnabled: false` hides generated rows without deleting exact bindings; an + omitted flag preserves the established behavior of a nonempty hand-written selector map. - **Rotation is sticky.** A conversation stays on its selected account while that account is usable; failure moves it, success does not (`src/codex/pool-rotation.ts`). - **The credential store is generation-guarded.** A refresh takes a lock and persists only if the diff --git a/structure/05_gui-and-management-api.md b/structure/05_gui-and-management-api.md index 0bb6fe7ed..a54ab1c70 100644 --- a/structure/05_gui-and-management-api.md +++ b/structure/05_gui-and-management-api.md @@ -65,7 +65,7 @@ this document owns is which module holds which area and what invariant that area | Endpoint area | Responsibility | | --- | --- | -| Config/settings | Read safe config/settings views; mutate supported settings only. Full `PUT /api/config` is disabled so masked secrets are not round-tripped. `PUT /api/settings` accepts `codexAutoStart`, `streamMode`, and/or integer `appOwnedMemoryBudgetMb` (64..4096; each optional, at least one required). Budget changes synchronously enforce the process-wide evictable retained-state cap; this is separate from RSS/native memory. `streamMode` persists the #314 stream-shape selection in config.json (Windows services need persisted input; macOS eager relay is explicit-only). | +| Config/settings | Read safe config/settings views; mutate supported settings only. Full `PUT /api/config` is disabled so masked secrets are not round-tripped. `PUT /api/settings` accepts `codexAutoStart`, `streamMode`, integer `appOwnedMemoryBudgetMb` (64..4096), and/or boolean `codexAccountPickerEnabled` (each optional, at least one required). Enabling the picker initializes privacy-safe selector bindings only when none exist; disabling it preserves exact routing bindings. The mutation is persisted before catalog refresh, and a failed refresh returns 200 with `catalogRefreshPending` so clients can direct the user to `ocx sync`. Budget changes synchronously enforce the process-wide evictable retained-state cap; this is separate from RSS/native memory. `streamMode` persists the #314 stream-shape selection in config.json (Windows services need persisted input; macOS eager relay is explicit-only). | | Startup safety | `GET /api/startup-health` reports whether injected Codex routing is restart-safe, with secret-free service/shim diagnostics. `POST /api/startup-action` provides allowlisted one-click installation for the background service or launcher shim. On Windows a healthy script shim is CLI-only; Codex Desktop requires the background service for full protection. | | Windows tray | `GET/POST /api/windows-tray` controls an owned, per-user HKCU login tray. The tray delegates fixed actions to the CLI and is never a proxy supervisor or restart-protection signal. | | Updates | `GET /api/update/check`, `POST /api/update/run`, and `GET /api/update/status` own dashboard self-update state. A launched worker PID is persisted in `update-job.json`; dead PIDs recover immediately, while legacy active records without a PID recover only after ten minutes. Live PIDs remain exclusive regardless of record age. `GET /api/update/badge` backs the sidebar badge: it reports that an update exists and links to the update surface rather than gating other actions. | @@ -88,7 +88,7 @@ this document owns is which module holds which area and what invariant that area | Effort and fallback | `src/server/management/agent-settings-routes.ts` — `GET/PUT /api/effort-caps`, `/api/subagent-models`, `/api/subagent-model-fallback`. Caps clamp; they do not reject. | | Grok and Claude integrations | `src/server/management/agent-settings-routes.ts` — `GET /api/grok`, `PUT /api/grok/selection`, `POST /api/grok/apply`, `GET/PUT /api/claude-desktop`, `POST /api/claude-desktop/apply`, `GET /api/claude-desktop/status`, `GET/PUT /api/claude-code`. Apply writes an external app's profile, so its status probe must read the same resolved path it writes (see [`04_transports-and-sidecars.md`](04_transports-and-sidecars.md)). | | Combos | `src/server/management/combo-routes.ts` — `GET/PUT/DELETE /api/combos` own provider combination and failover definitions. | -| Codex accounts | `src/codex/auth-api.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. | +| Codex accounts | `src/codex/auth-api.ts` — `GET/POST/DELETE /api/codex-auth/accounts`, `PUT /api/codex-auth/accounts/alias`, `PUT /api/codex-auth/accounts/pause`, `PUT /api/codex-auth/accounts/pause-exhausted`, `POST /api/codex-auth/accounts/clear-cooldown`, `GET/PUT /api/codex-auth/active`, `PUT /api/codex-auth/auto-switch`, `PUT /api/codex-auth/pool-strategy`, `PUT /api/codex-auth/failover`, `GET /api/codex-auth/quota`, `GET /api/codex-auth/reset-credits` with `POST /api/codex-auth/reset-credits/consume`, and the login flow `POST /api/codex-auth/login`, `POST /api/codex-auth/login/code`, `POST /api/codex-auth/login/cancel`, `GET /api/codex-auth/login-status`. Account ids are opaque handles and are serialized so the GUI can address an account; emails are masked and tokens are never serialized. UI-managed selector maps append privacy-safe bindings for new accounts and retain them across deletion; add/delete/login responses report `catalogRefreshPending` after durable persistence when catalog refresh cannot complete. | | Sidebar | `src/server/management/sidebar-routes.ts` — `GET/POST /api/github/star` and `GET /api/update/badge`. Sidebar state is cosmetic; a failed fetch degrades silently. | | Logs | `src/server/management/logs-usage-routes.ts` — `GET /api/logs`, `GET /api/claude/inbound-debug`, and `GET /api/debug/injection-logs` join the debug streams described above. | diff --git a/tests/codex-account-namespaces.test.ts b/tests/codex-account-namespaces.test.ts index 5083f7c99..a035bb66e 100644 --- a/tests/codex-account-namespaces.test.ts +++ b/tests/codex-account-namespaces.test.ts @@ -12,10 +12,12 @@ import { } from "../src/codex/account-namespace-match"; import { appendDefaultCodexAccountNamespace, + codexAccountPickerIsEnabled, codexAccountNamespaceEntries, defaultCodexAccountNamespaces, isMainCodexAccountTarget, isValidCodexAccountNamespaceTarget, + visibleCodexAccountNamespaceEntries, } from "../src/codex/account-namespaces"; import { MAIN_CODEX_ACCOUNT_ID } from "../src/codex/main-account"; @@ -306,6 +308,66 @@ describe("Codex account namespace foundations", () => { })).toEqual({ "main-2": "@main", p454545: "main" }); }); + test("keeps existing selector maps enabled unless the visibility override is false", () => { + expect(codexAccountPickerIsEnabled({ codexAccountNamespaces: { desktop: "@main" } })).toBe(true); + expect(codexAccountPickerIsEnabled({ + codexAccountNamespaces: { desktop: "@main" }, + codexAccountPickerEnabled: true, + })).toBe(true); + expect(codexAccountPickerIsEnabled({ + codexAccountNamespaces: { desktop: "@main" }, + codexAccountPickerEnabled: false, + })).toBe(false); + expect(codexAccountPickerIsEnabled({ + codexAccountNamespaces: {}, + codexAccountPickerEnabled: true, + })).toBe(false); + expect(codexAccountPickerIsEnabled({})).toBe(false); + }); + + test("advertises only public selectors backed by available accounts", () => { + const config = { + codexAccounts: [{ + id: "stored-account-id", + email: "private@example.test", + alias: "Private Display Alias", + isMain: false, + }], + codexAccountNamespaces: { + desktop: "@main", + team: "stored-account-id", + removed: "missing-account-id", + }, + }; + + expect(visibleCodexAccountNamespaceEntries(config)).toEqual([ + ["desktop", MAIN_CODEX_ACCOUNT_ID], + ["team", "stored-account-id"], + ]); + expect(visibleCodexAccountNamespaceEntries(config).length).toBeGreaterThan(0); + expect(JSON.stringify(visibleCodexAccountNamespaceEntries(config))) + .not.toContain("private@example.test"); + expect(JSON.stringify(visibleCodexAccountNamespaceEntries(config))) + .not.toContain("Private Display Alias"); + }); + + test("hiding picker rows leaves exact routing bindings unchanged", () => { + const codexAccountNamespaces = { desktop: "@main", team: "stored-account-id" }; + const config = { + codexAccounts: [{ id: "stored-account-id", isMain: false }], + codexAccountNamespaces, + codexAccountPickerEnabled: false, + }; + + expect(visibleCodexAccountNamespaceEntries(config)).toEqual([]); + expect(visibleCodexAccountNamespaceEntries(config)).toHaveLength(0); + expect(codexAccountNamespaceEntries(config)).toEqual([ + ["desktop", MAIN_CODEX_ACCOUNT_ID], + ["team", "stored-account-id"], + ]); + expect(config.codexAccountNamespaces).toBe(codexAccountNamespaces); + }); + test("matches route and account namespaces exactly but provider namespaces case-insensitively", () => { const inherited = Object.create({ inherited: "account-id" }) as Record; inherited.side = "side-account-id"; diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 65ee1e553..8094b83bf 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -63,6 +63,7 @@ import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, } from "../src/providers/openai-sidecar"; +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"); @@ -123,7 +124,7 @@ async function completeMockCodexOAuth(options: { email: string; onWarmup: () => void; usageResponse?: () => Response; -}): Promise<{ startStatus: number; state: { status: string; error?: string } }> { +}): Promise<{ startStatus: number; state: { status: string; error?: string; catalogRefreshPending?: boolean } }> { const oauth = await import("../src/oauth"); const oauthStore = await import("../src/oauth/store"); const openUrlMod = await import("../src/lib/open-url"); @@ -180,7 +181,7 @@ async function completeMockCodexOAuth(options: { { method: "GET" }, ); const statusResp = await handleCodexAuthAPI(statusReq, new URL(statusReq.url), options.config); - const state = await statusResp!.json() as { status: string; error?: string }; + const state = await statusResp!.json() as { status: string; error?: string; catalogRefreshPending?: boolean }; if (state.status !== "pending") return { startStatus: resp!.status, state }; await new Promise(resolve => queueMicrotask(resolve)); } @@ -2245,6 +2246,83 @@ describe("codex-auth API", () => { expect(warmup.calls()).toBe(1); }); + test.each([ + ["succeeds", false, 1], + ["fails twice", true, 2], + ] as const)("UI-managed manual add is durable before catalog refresh %s", async (_label, pending, attempts) => { + enableManualImport(); + mockCodexWarmupSuccess(); + const accountId = pending ? "manual-picker-pending" : "manual-picker-ready"; + const config = makeConfig({ + codexAccountNamespaces: { desktop: "@main" }, + codexAccountPickerEnabled: true, + }); + const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog").mockImplementation(async () => { + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.codexAccounts?.some(account => account.id === accountId)).toBe(true); + expect(getCodexAccountCredential(accountId)).not.toBeNull(); + if (pending) throw new Error("private refresh details"); + return { + added: 1, path: "catalog.json", catalogExists: false, catalogWritten: false, + cacheSynced: false, comboOmissions: [], + }; + }); + 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: accountId })), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const body = await resp!.json() as { ok?: boolean; catalogRefreshPending?: boolean }; + const binding = Object.entries(config.codexAccountNamespaces ?? {}) + .find(([, target]) => target === accountId); + expect(resp!.status).toBe(200); + expect(body).toEqual({ ok: true, catalogRefreshPending: pending }); + expect(binding?.[0]).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); + expect(binding?.[0]).not.toContain(accountId); + expect(refreshSpy).toHaveBeenCalledTimes(attempts); + expect(warnSpy).toHaveBeenCalledTimes(pending ? 1 : 0); + if (pending) { + expect(String(warnSpy.mock.calls[0]?.[0])).toContain("ocx sync"); + expect(String(warnSpy.mock.calls[0]?.[0])).not.toContain("private refresh details"); + } + } finally { + warnSpy.mockRestore(); + refreshSpy.mockRestore(); + } + }); + + test("manual maps stay manual while a disabled UI-managed map still tracks new accounts", async () => { + enableManualImport(); + mockCodexWarmupSuccess(); + const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog"); + try { + for (const [accountId, enabled, expectedBinding] of [ + ["manual-map-add", undefined, false], + ["hidden-picker-add", false, true], + ] as const) { + const config = makeConfig({ + codexAccountNamespaces: { desktop: "@main" }, + ...(enabled === undefined ? {} : { codexAccountPickerEnabled: enabled }), + }); + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: accountId, email: `${accountId}@example.test`, chatgptAccountId: `acct-${accountId}` })), + }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ ok: true, catalogRefreshPending: false }); + expect(Object.values(config.codexAccountNamespaces ?? {}).includes(accountId)).toBe(expectedBinding); + } + expect(refreshSpy).not.toHaveBeenCalled(); + } finally { + refreshSpy.mockRestore(); + } + }); + test("POST /api/codex-auth/accounts allows a pool account matching the main login", async () => { enableManualImport(); mockCodexWarmupSuccess(); @@ -2939,6 +3017,39 @@ describe("codex-auth API", () => { expect(isAccountNeedsReauth("pool-delete")).toBe(false); }); + test("enabled picker deletion retains its binding and refreshes after durable removal", async () => { + const accountId = "picker-delete"; + const config = makeConfig({ + codexAccounts: [{ id: accountId, email: "delete@example.test", isMain: false }], + codexAccountNamespaces: { team: accountId }, + codexAccountPickerEnabled: true, + }); + saveCodexAccountCredential(accountId, { + accessToken: "delete-access", refreshToken: "delete-refresh", + expiresAt: Date.now() + 60_000, chatgptAccountId: "delete-chatgpt-id", + }); + const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog").mockImplementation(async () => { + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; + expect(persisted.codexAccounts).toEqual([]); + expect(persisted.codexAccountNamespaces).toEqual({ team: accountId }); + expect(getCodexAccountCredential(accountId)).toBeNull(); + return { + added: 0, path: "catalog.json", catalogExists: false, catalogWritten: false, + cacheSynced: false, comboOmissions: [], + }; + }); + try { + const req = new Request(`http://localhost/api/codex-auth/accounts?id=${accountId}`, { method: "DELETE" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + expect(resp!.status).toBe(200); + expect(await resp!.json()).toEqual({ ok: true, catalogRefreshPending: false }); + expect(config.codexAccountNamespaces).toEqual({ team: accountId }); + expect(refreshSpy).toHaveBeenCalledTimes(1); + } finally { + refreshSpy.mockRestore(); + } + }); + test.each([ MAIN_CODEX_ACCOUNT_ID, "__proto__", @@ -3424,6 +3535,39 @@ describe("codex-auth API", () => { expect(getCodexAccountCredential("oauth-race")).toBeNull(); }); + test("OAuth creation reports a durable add when catalog refresh remains pending", async () => { + const accountId = "oauth-picker-pending"; + const config = makeConfig({ + codexAccountNamespaces: { desktop: "@main" }, + codexAccountPickerEnabled: true, + }); + const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog") + .mockRejectedValue(new Error("private oauth refresh details")); + const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); + try { + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: accountId }, + oauthAccountId: "oauth-picker-chatgpt-id", + email: "oauth-picker@example.test", + onWarmup: () => {}, + }); + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ status: "done", catalogRefreshPending: true }); + expect(config.codexAccounts?.some(account => account.id === accountId)).toBe(true); + expect(Object.values(config.codexAccountNamespaces ?? {})).toContain(accountId); + expect(getCodexAccountCredential(accountId)).not.toBeNull(); + expect((JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig).codexAccounts) + .toEqual(expect.arrayContaining([expect.objectContaining({ id: accountId })])); + expect(refreshSpy).toHaveBeenCalledTimes(2); + expect(String(warnSpy.mock.calls[0]?.[0])).toContain("ocx sync"); + expect(String(warnSpy.mock.calls[0]?.[0])).not.toContain("private oauth refresh details"); + } finally { + warnSpy.mockRestore(); + refreshSpy.mockRestore(); + } + }); + test("OAuth reauth cannot recreate an account deleted during warmup", async () => { const config = makeConfig({ codexAccounts: [{ id: "reauth-race", email: "reauth-race@example.test", isMain: false }], @@ -3529,7 +3673,9 @@ describe("codex-auth API", () => { test("OAuth pool login stores a privacy log label at the account creation call site", async () => { const source = await Bun.file("src/codex/auth-api.ts").text(); - expect(source).toContain("withCodexAccountLogLabel({ id: accountId, email, plan, isMain: false }, accounts)"); + expect(source).toMatch( + /withCodexAccountLogLabel\(\s*\{ id: accountId, email, plan, isMain: false \},\s*accounts,?\s*\)/, + ); }); test("GET /api/codex-auth/login-status masks transient flow-state emails at response boundaries", async () => { diff --git a/tests/config.test.ts b/tests/config.test.ts index e5855aa49..f95d99630 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1447,7 +1447,7 @@ describe("opencodex config defaults", () => { expect(isValidProviderName("constructor")).toBe(false); }); - test("persists an explicit Codex account selector map without enabling it by default", () => { + test("persists an explicit Codex account selector map without adding one to defaults", () => { const selectors = { desktop: "@main", work: "work-account", @@ -1462,6 +1462,25 @@ describe("opencodex config defaults", () => { expect(Object.hasOwn(getDefaultConfig(), "codexAccountNamespaces")).toBe(false); }); + test("persists the optional Codex account picker visibility override", () => { + for (const enabled of [true, false]) { + writeAccountNamespaceConfig({ desktop: "@main" }, { codexAccountPickerEnabled: enabled }); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.error).toBeNull(); + expect(diagnostics.config.codexAccountPickerEnabled).toBe(enabled); + } + expect(Object.hasOwn(getDefaultConfig(), "codexAccountPickerEnabled")).toBe(false); + }); + + test("rejects a non-boolean Codex account picker visibility override", () => { + writeAccountNamespaceConfig({ desktop: "@main" }, { codexAccountPickerEnabled: "yes" }); + + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("codexAccountPickerEnabled"); + }); + test("validates Claude Desktop profiles and Codex account selectors independently", () => { const desktopProfile = { version: 1, diff --git a/tests/native-model-toggle.test.ts b/tests/native-model-toggle.test.ts index d2f9615ab..5cdd68233 100644 --- a/tests/native-model-toggle.test.ts +++ b/tests/native-model-toggle.test.ts @@ -272,6 +272,28 @@ describe("native GPT model toggles (bare slugs in disabledModels)", () => { .not.toContain("stored-side-account"); }); + test("picker visibility hides generated catalog rows without deleting routing bindings", () => { + const codexAccountNamespaces = { desktop: "@main", team: "stored-side-account" }; + const config = { + codexAccounts: [{ id: "stored-side-account", isMain: false }], + codexAccountNamespaces, + codexAccountPickerEnabled: false, + }; + + expect(visibleCodexAccountSelectors(config)).toEqual([]); + expect(accountBoundNativeModelSlugs(config, ["gpt-5.5"])).toEqual([]); + expect(config.codexAccountNamespaces).toBe(codexAccountNamespaces); + + config.codexAccountPickerEnabled = true; + expect(visibleCodexAccountSelectors(config)).toEqual(["desktop", "team"]); + + expect(visibleCodexAccountSelectors({ + codexAccounts: config.codexAccounts, + codexAccountNamespaces: {}, + codexAccountPickerEnabled: true, + })).toEqual([]); + }); + test("catalog sync flips supported natives to visibility hide and restores list on re-enable", () => { const native = nativeTemplate(); const disabledOnce = mergeCatalogEntriesForSync( diff --git a/tests/router.test.ts b/tests/router.test.ts index c4d992516..ba8152108 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -130,6 +130,14 @@ describe("routeModel registry effort defaults", () => { }); expect(() => routeModel(config, "side/claude-opus-4-6")) .toThrow("only supports native OpenAI model ids"); + + config.codexAccountPickerEnabled = false; + expect(routeModel(config, "side/gpt-5.5")).toMatchObject({ + providerName: "openai", + modelId: "gpt-5.5", + codexAccountId: "side-account-id", + codexAccountNamespace: "side", + }); }); test("requires an enabled canonical OpenAI forward provider before exact credential injection", () => { diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index 7b2320fa7..ff77b2af3 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -7,12 +7,12 @@ * backup-and-defaults repair path), and settable alone via PUT (legacy * codexAutoStart-only PUTs keep working). */ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getConfigPath, loadConfig, saveConfig } from "../src/config"; -import { handleManagementAPI } from "../src/server/management-api"; +import { handleManagementAPI, type ManagementApiDeps } from "../src/server/management-api"; import { invalidateStartupHealthCache } from "../src/server/startup-health-cache"; import type { OcxConfig } from "../src/types"; import { @@ -47,13 +47,17 @@ function baseConfig(): OcxConfig { }; } -function putSettings(config: OcxConfig, body: unknown): Promise { +function putSettings( + config: OcxConfig, + body: unknown, + deps: ManagementApiDeps = {}, +): 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, deps); } function getSettings(config: OcxConfig): Promise { @@ -104,6 +108,25 @@ describe("GET /api/settings", () => { expect(body.appOwnedMemoryBudgetMb).toBe(256); }); + test("reports the effective account-picker state", async () => { + const absent = await (await getSettings(baseConfig()))!.json() as { + codexAccountPickerEnabled?: boolean; + }; + const inferred = await (await getSettings({ + ...baseConfig(), + codexAccountNamespaces: { side: "stored-account" }, + }))!.json() as { codexAccountPickerEnabled?: boolean }; + const hidden = await (await getSettings({ + ...baseConfig(), + codexAccountNamespaces: { side: "stored-account" }, + codexAccountPickerEnabled: false, + }))!.json() as { codexAccountPickerEnabled?: boolean }; + + expect(absent.codexAccountPickerEnabled).toBe(false); + expect(inferred.codexAccountPickerEnabled).toBe(true); + expect(hidden.codexAccountPickerEnabled).toBe(false); + }); + test("reports redacted codexRuntime diagnostics and clamp correlation", async () => { const { chmodSync } = await import("node:fs"); const { @@ -260,6 +283,145 @@ describe("PUT /api/settings", () => { expect(res!.status).toBe(400); }); + test("account-picker enable persists before refresh and retries one failure", async () => { + const config = baseConfig(); + let persisted = false; + let refreshes = 0; + const response = await putSettings(config, { codexAccountPickerEnabled: true }, { + saveConfigPreservingClaudeCode: saved => { + persisted = true; + expect(saved.codexAccountPickerEnabled).toBe(true); + expect(saved.codexAccountNamespaces).toEqual({ main: "@main" }); + }, + refreshCodexCatalog: async () => { + expect(persisted).toBe(true); + refreshes += 1; + if (refreshes === 1) throw new Error("first refresh failed"); + }, + }); + + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ + codexAccountPickerEnabled: true, + catalogRefreshPending: false, + }); + expect(refreshes).toBe(2); + expect(config.codexAccountNamespaces).toEqual({ main: "@main" }); + }); + + test("account-picker disable does not initialize an empty namespace map", async () => { + const config = baseConfig(); + let refreshes = 0; + const response = await putSettings(config, { codexAccountPickerEnabled: false }, { + saveConfigPreservingClaudeCode: () => {}, + refreshCodexCatalog: async () => { refreshes += 1; }, + }); + + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ + codexAccountPickerEnabled: false, + catalogRefreshPending: false, + }); + expect(config.codexAccountNamespaces).toBeUndefined(); + expect(refreshes).toBe(0); + }); + + test("account-picker refresh remains a successful persisted mutation when both attempts fail", async () => { + const warning = spyOn(console, "warn").mockImplementation(() => {}); + const config = { + ...baseConfig(), + codexAccountNamespaces: { main: "@main" }, + codexAccountPickerEnabled: false, + }; + let persisted = false; + let refreshes = 0; + try { + const response = await putSettings(config, { codexAccountPickerEnabled: true }, { + saveConfigPreservingClaudeCode: () => { persisted = true; }, + refreshCodexCatalog: async () => { + expect(persisted).toBe(true); + refreshes += 1; + throw new Error("private refresh failure detail"); + }, + }); + + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ + codexAccountPickerEnabled: true, + catalogRefreshPending: true, + }); + expect(refreshes).toBe(2); + const warningText = warning.mock.calls.flat().join(" "); + expect(warningText).toContain("ocx sync"); + expect(warningText).not.toContain("private refresh failure detail"); + } finally { + warning.mockRestore(); + } + }); + + test("account-picker disable and re-enable preserve custom namespace order", async () => { + const namespaces = { side: "stored-account", main: "@main" }; + const config = { ...baseConfig(), codexAccountNamespaces: namespaces }; + const persistedOrders: string[][] = []; + let refreshes = 0; + const deps: ManagementApiDeps = { + saveConfigPreservingClaudeCode: saved => { + persistedOrders.push(Object.keys(saved.codexAccountNamespaces ?? {})); + }, + refreshCodexCatalog: async () => { refreshes += 1; }, + }; + + const disabled = await putSettings(config, { codexAccountPickerEnabled: false }, deps); + expect(await disabled!.json()).toMatchObject({ + codexAccountPickerEnabled: false, + catalogRefreshPending: false, + }); + const reenabled = await putSettings(config, { codexAccountPickerEnabled: true }, deps); + expect(await reenabled!.json()).toMatchObject({ + codexAccountPickerEnabled: true, + catalogRefreshPending: false, + }); + + expect(config.codexAccountNamespaces).toBe(namespaces); + expect(persistedOrders).toEqual([["side", "main"], ["side", "main"]]); + expect(refreshes).toBe(2); + }); + + test("account-picker rejects non-boolean values before persistence or refresh", async () => { + let persisted = false; + let refreshed = false; + const response = await putSettings(baseConfig(), { codexAccountPickerEnabled: "yes" }, { + saveConfigPreservingClaudeCode: () => { persisted = true; }, + refreshCodexCatalog: async () => { refreshed = true; }, + }); + + expect(response!.status).toBe(400); + expect(await response!.json()).toMatchObject({ + error: expect.stringContaining("codexAccountPickerEnabled"), + }); + expect(persisted).toBe(false); + expect(refreshed).toBe(false); + }); + + test("failed persistence rolls back picker state before returning", async () => { + const config = baseConfig(); + const before = structuredClone(config); + let refreshed = false; + const request = putSettings(config, { + codexAutoStart: false, + streamMode: "legacy-tee", + appOwnedMemoryBudgetMb: 128, + codexAccountPickerEnabled: true, + }, { + saveConfigPreservingClaudeCode: () => { throw new Error("save failed"); }, + refreshCodexCatalog: async () => { refreshed = true; }, + }); + + await expect(request).rejects.toThrow("save failed"); + expect(config).toEqual(before); + expect(refreshed).toBe(false); + }); + test("settings PUT rejects below above fractional and nonnumeric budget values", async () => { for (const value of [63, 4097, 64.5, "64"]) { const res = await putSettings(baseConfig(), { appOwnedMemoryBudgetMb: value }); From 18f6e49703229256ed237d5cc5721aff0cbe6335 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 14:25:34 -0400 Subject: [PATCH 02/19] fix(codex): harden account picker mutations --- src/codex/auth-api.ts | 65 ++++++++++----- src/server/management/config-routes.ts | 55 +++++++------ tests/codex-auth-api.test.ts | 106 +++++++++++++++++++++++-- tests/settings-stream-mode.test.ts | 40 ++++++++++ 4 files changed, 217 insertions(+), 49 deletions(-) diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 67ff7cdf2..795b47103 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -389,6 +389,41 @@ function saveRuntimeConfig(sourceConfig: OcxConfig, nextConfig: OcxConfig): void Object.assign(sourceConfig, nextConfig); } +/** + * Persist a new account without exposing nondurable selector state. The live + * config object's identity is significant to saveConfigPreservingClaudeCode's + * WeakMap baselines, so mutate and save synchronously, then restore exact prior + * references if either selector allocation or persistence fails. + */ +function persistNewCodexAccount( + sourceConfig: OcxConfig, + runtimeConfig: OcxConfig, + addedAccount: CodexAccount, +): { pickerVisibilityChanged: boolean } { + const previousConfig = { ...runtimeConfig }; + try { + const accounts = [...(runtimeConfig.codexAccounts ?? [])]; + const retainedPickerBindingRestored = codexAccountPickerIsEnabled(runtimeConfig) + && Object.values(runtimeConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id); + accounts.push(addedAccount); + runtimeConfig.codexAccounts = accounts; + if (runtimeConfig.codexAccountPickerEnabled !== undefined + && runtimeConfig.codexAccountNamespaces) { + runtimeConfig.codexAccountNamespaces = { ...runtimeConfig.codexAccountNamespaces }; + } + const namespaceAdded = runtimeConfig.codexAccountPickerEnabled !== undefined + && appendDefaultCodexAccountNamespace(runtimeConfig, addedAccount); + saveRuntimeConfig(sourceConfig, runtimeConfig); + return { pickerVisibilityChanged: namespaceAdded || retainedPickerBindingRestored }; + } catch (error) { + for (const key of Object.keys(runtimeConfig) as Array) { + delete runtimeConfig[key]; + } + Object.assign(runtimeConfig, previousConfig); + throw error; + } +} + async function refreshAccountNamespaceCatalog(config: OcxConfig, changed: boolean): Promise { if (!changed || !codexAccountPickerIsEnabled(config)) return false; return refreshCodexCatalogWithRetry(async () => { @@ -1249,22 +1284,19 @@ export async function handleCodexAuthAPI( }); markCodexAccountValidated(body.id, warmup.validatedAt); clearAccountNeedsReauth(body.id); - const accounts = latestConfig.codexAccounts ?? []; const addedAccount = withCodexAccountLogLabel( { id: body.id, email: body.email, plan: body.plan, isMain: false }, - accounts, + latestConfig.codexAccounts ?? [], + ); + const { pickerVisibilityChanged } = persistNewCodexAccount( + config, + latestConfig, + addedAccount, ); - const retainedPickerBindingRestored = codexAccountPickerIsEnabled(latestConfig) - && Object.values(latestConfig.codexAccountNamespaces ?? {}).includes(addedAccount.id); - accounts.push(addedAccount); - latestConfig.codexAccounts = accounts; - const namespaceAdded = latestConfig.codexAccountPickerEnabled !== undefined - && appendDefaultCodexAccountNamespace(latestConfig, addedAccount); - saveRuntimeConfig(config, latestConfig); reconcileLiveStateStores(); const catalogRefreshPending = await refreshAccountNamespaceCatalog( latestConfig, - namespaceAdded || retainedPickerBindingRestored, + pickerVisibilityChanged, ); return jsonResponse({ ok: true, catalogRefreshPending }); } @@ -1761,14 +1793,11 @@ export async function handleCodexAuthAPI( { 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 = latestConfig.codexAccountPickerEnabled !== undefined - && appendDefaultCodexAccountNamespace(latestConfig, addedAccount); - saveRuntimeConfig(config, latestConfig); - pickerVisibilityChanged = namespaceAdded || retainedPickerBindingRestored; + ({ pickerVisibilityChanged } = persistNewCodexAccount( + config, + latestConfig, + addedAccount, + )); } reconcileLiveStateStores(); const catalogRefreshPending = await refreshAccountNamespaceCatalog( diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 98b5c607f..0c91840f6 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -219,13 +219,22 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise { expect(getCodexAccountCredential(accountId)).toBeNull(); }); + test("manual add never publishes an account selector before config commit", async () => { + enableManualImport(); + mockCodexWarmupSuccess(); + const accountId = "manual-picker-lock-busy"; + const config = makeConfig({ + codexAccountNamespaces: { desktop: "@main" }, + codexAccountPickerEnabled: true, + }); + saveConfig(structuredClone(config)); + const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog"); + const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") + .mockImplementation(candidate => { + expect(candidate).toBe(config); + throw new ConfigMutationLockError("test config commit failed"); + }); + + try { + const req = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: accountId })), + }); + + await expect(handleCodexAuthAPI(req, new URL(req.url), config)) + .rejects.toBeInstanceOf(ConfigMutationLockError); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ desktop: "@main" }); + expect(Object.values(config.codexAccountNamespaces ?? {})).not.toContain(accountId); + expect(loadConfig()).toMatchObject({ + codexAccounts: [], + codexAccountNamespaces: { desktop: "@main" }, + }); + expect(getCodexAccountCredential(accountId)).not.toBeNull(); + expect(refreshSpy).not.toHaveBeenCalled(); + } finally { + saveSpy.mockRestore(); + refreshSpy.mockRestore(); + } + }); + 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", { @@ -2257,7 +2300,8 @@ describe("codex-auth API", () => { codexAccountNamespaces: { desktop: "@main" }, codexAccountPickerEnabled: true, }); - const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog").mockImplementation(async () => { + const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog").mockImplementation(async refreshConfig => { + expect(refreshConfig).toBe(config); const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; expect(persisted.codexAccounts?.some(account => account.id === accountId)).toBe(true); expect(getCodexAccountCredential(accountId)).not.toBeNull(); @@ -3542,7 +3586,10 @@ describe("codex-auth API", () => { codexAccountPickerEnabled: true, }); const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog") - .mockRejectedValue(new Error("private oauth refresh details")); + .mockImplementation(async refreshConfig => { + expect(refreshConfig).toBe(config); + throw new Error("private oauth refresh details"); + }); const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); try { const result = await completeMockCodexOAuth({ @@ -3568,6 +3615,49 @@ describe("codex-auth API", () => { } }); + test("OAuth add never publishes an account selector before config commit", async () => { + const accountId = "oauth-picker-lock-busy"; + const config = makeConfig({ + codexAccountNamespaces: { desktop: "@main" }, + codexAccountPickerEnabled: true, + }); + saveConfig(structuredClone(config)); + const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog"); + const saveSpy = spyOn(configModule, "saveConfigPreservingClaudeCode") + .mockImplementation(candidate => { + expect(candidate).toBe(config); + throw new ConfigMutationLockError("test config commit failed"); + }); + + try { + const result = await completeMockCodexOAuth({ + config, + requestBody: { id: accountId }, + oauthAccountId: "oauth-picker-lock-chatgpt-id", + email: "oauth-picker-lock@example.test", + onWarmup: () => {}, + }); + + expect(result.startStatus).toBe(200); + expect(result.state).toMatchObject({ + status: "error", + error: "Configuration is busy; retry login shortly.", + }); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ desktop: "@main" }); + expect(Object.values(config.codexAccountNamespaces ?? {})).not.toContain(accountId); + expect(loadConfig()).toMatchObject({ + codexAccounts: [], + codexAccountNamespaces: { desktop: "@main" }, + }); + expect(getCodexAccountCredential(accountId)).not.toBeNull(); + expect(refreshSpy).not.toHaveBeenCalled(); + } finally { + saveSpy.mockRestore(); + refreshSpy.mockRestore(); + } + }); + test("OAuth reauth cannot recreate an account deleted during warmup", async () => { const config = makeConfig({ codexAccounts: [{ id: "reauth-race", email: "reauth-race@example.test", isMain: false }], diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index ff77b2af3..717a2d303 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -283,6 +283,17 @@ describe("PUT /api/settings", () => { expect(res!.status).toBe(400); }); + test.each([[null], [[]], ["settings"], [42]] as const)( + "rejects a non-object settings body with 400 (%j)", + async body => { + const config = baseConfig(); + const response = await putSettings(config, body); + + expect(response!.status).toBe(400); + expect(await response!.json()).toEqual({ error: "settings body must be an object" }); + }, + ); + test("account-picker enable persists before refresh and retries one failure", async () => { const config = baseConfig(); let persisted = false; @@ -422,6 +433,35 @@ describe("PUT /api/settings", () => { expect(refreshed).toBe(false); }); + test("selector allocation failure rolls back every setting before persistence", async () => { + const config = baseConfig(); + Object.defineProperty(config, "codexAccounts", { + configurable: true, + get: () => { throw new Error("selector allocation failed"); }, + }); + let persisted = false; + let refreshed = false; + + const request = putSettings(config, { + codexAutoStart: false, + streamMode: "legacy-tee", + appOwnedMemoryBudgetMb: 128, + codexAccountPickerEnabled: true, + }, { + saveConfigPreservingClaudeCode: () => { persisted = true; }, + refreshCodexCatalog: async () => { refreshed = true; }, + }); + + await expect(request).rejects.toThrow("selector allocation failed"); + expect(Object.hasOwn(config, "codexAutoStart")).toBe(false); + expect(Object.hasOwn(config, "streamMode")).toBe(false); + expect(Object.hasOwn(config, "appOwnedMemoryBudgetMb")).toBe(false); + expect(Object.hasOwn(config, "codexAccountNamespaces")).toBe(false); + expect(Object.hasOwn(config, "codexAccountPickerEnabled")).toBe(false); + expect(persisted).toBe(false); + expect(refreshed).toBe(false); + }); + test("settings PUT rejects below above fractional and nonnumeric budget values", async () => { for (const value of [63, 4097, 64.5, "64"]) { const res = await putSettings(baseConfig(), { appOwnedMemoryBudgetMb: value }); From eae13eb170ba4eced5f8bfd11f6245af740d98e3 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 14:52:16 -0400 Subject: [PATCH 03/19] fix(codex): complete picker catalog refreshes --- .../content/docs/guides/codex-app-models.md | 20 +++++---- .../src/content/docs/guides/web-dashboard.md | 2 +- .../docs/ja/guides/codex-app-models.md | 6 +-- .../content/docs/ja/guides/web-dashboard.md | 2 +- .../ja/reference/configuration/providers.md | 3 +- .../docs/ja/reference/management-api.md | 12 +++-- .../docs/ko/guides/codex-app-models.md | 16 ++++--- .../content/docs/ko/guides/web-dashboard.md | 2 +- .../ko/reference/configuration/providers.md | 3 +- .../docs/ko/reference/management-api.md | 12 +++-- .../docs/reference/configuration/providers.md | 3 +- .../content/docs/reference/management-api.md | 12 +++-- .../docs/ru/guides/codex-app-models.md | 17 +++---- .../content/docs/ru/guides/web-dashboard.md | 2 +- .../ru/reference/configuration/providers.md | 3 +- .../docs/ru/reference/management-api.md | 12 +++-- .../docs/zh-cn/guides/codex-app-models.md | 6 +-- .../docs/zh-cn/guides/web-dashboard.md | 2 +- .../reference/configuration/providers.md | 3 +- .../docs/zh-cn/reference/management-api.md | 11 +++-- src/codex/auth-api.ts | 7 ++- src/codex/catalog-refresh-status.ts | 17 +++++++ src/server/management-api.ts | 10 +++-- src/server/management/context.ts | 6 ++- tests/codex-auth-api.test.ts | 45 +++++++++++++++++-- tests/settings-stream-mode.test.ts | 37 ++++++++++++++- 26 files changed, 205 insertions(+), 66 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 534f97e27..81cdb11a9 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -9,13 +9,15 @@ App's model picker as normal Codex catalog entries. OpenAI entries use two credential routes: native Codex login and the namespaced `openai-apikey/` API-key transport. Changing `codexAccountMode` between Pool and Direct by -itself does not change picker ids. When `codexAccountNamespaces` has eligible selectors whose -mapped accounts still exist, however, -opencodex adds separate `/` rows for the mapped accounts and hides -the bare native rows from the Codex picker. Selector labels are user-chosen public names with no -built-in account-role meaning. Selecting a qualified row uses only its mapped account, does not -change the active Pool account, and fails closed instead of switching accounts when the target is -unavailable. See [Exact Codex account selectors](/reference/configuration/routing/#exact-codex-account-selectors). +itself does not change picker ids. When account-picker visibility is enabled +(`codexAccountPickerEnabled` is not `false`) and `codexAccountNamespaces` has eligible selectors +whose mapped accounts still exist, opencodex adds separate `/` rows +for the mapped accounts and hides the bare native rows from the Codex picker. Setting +`codexAccountPickerEnabled: false` hides only those generated rows; configured qualified ids remain +exact routes and still fail closed instead of switching accounts. Selector labels are user-chosen +public names with no built-in account-role meaning. Selecting a qualified row uses only its mapped +account and does not change the active Pool account. See +[Exact Codex account selectors](/reference/configuration/routing/#exact-codex-account-selectors). API GPT-5.6 entries use 1,050,000 context / 922,000 max input, and `*-pro` picker ids resolve to the base wire model with `reasoning.mode: "pro"` while logs, usage, and picker state keep the virtual id. @@ -72,8 +74,8 @@ metadata instead of an older-template approximation. | Route | Picker ids and catalog metadata | | --- | --- | -| Codex login (no eligible account selectors) | Bare native ids such as `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`; Pool or Direct is selected through `codexAccountMode`. GPT-5.6 rows use a 372,000-token catalog window. | -| Codex login (eligible account selectors) | One `/` row per eligible selector and supported native model; each row uses only its mapped account, and bare native rows are hidden from the picker. Native metadata and context windows are preserved. | +| Codex login (picker hidden or no eligible account selectors) | Bare native ids such as `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`; Pool or Direct is selected through `codexAccountMode`. GPT-5.6 rows use a 372,000-token catalog window. Configured exact selector routes remain callable while hidden. | +| Codex login (picker enabled with eligible account selectors) | One `/` row per eligible selector and supported native model; each row uses only its mapped account, and bare native rows are hidden from the picker. Native metadata and context windows are preserved. | | OpenAI (API key) | Exactly eight namespaced rows: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, and the three `*-pro` virtual ids (1,050,000 context; 922,000 max input for all eight) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (1,050,000) | | Cursor | Static fallback includes `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, and `cursor/gpt-5.6-luna` (1,000,000), plus `cursor/grok-4.5` and `cursor/grok-4.5-fast` (500,000); live account discovery decides which remain visible. | diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index 660fbcfda..8a8c98ec6 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -153,7 +153,7 @@ The GUI is a thin client over the proxy's JSON management API. Useful endpoints | Endpoint | Purpose | | --- | --- | -| `GET` / `PUT /api/settings` | Read settings or toggle Codex autostart. | +| `GET` / `PUT /api/settings` | Read settings or update Codex autostart, stream mode, app-owned memory budget, and account-qualified picker visibility. A persisted picker change can return `catalogRefreshPending: true`; retry with `ocx sync`. | | `GET` / `POST /api/github/star` | Read the `gh`-derived star state, or star the repository. The POST is refused with `403` `agent_consent_required` for agent-driven callers without a dashboard session. | | `GET /api/startup-health` | Read secret-free routing, service, shim, and restart-safety diagnostics. | | `POST /api/startup-action` | Install the background service or Codex launcher shim through fixed, allowlisted actions. | 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 77a93e9fb..047871829 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 @@ -5,7 +5,7 @@ description: opencodex モデルが、共有 Codex カタログを通じて Code opencodex は Codex アプリにパッチを適用しません。 Codex CLI/TUI が既に使用しているのと同じ Codex 設定とモデル カタログを書き込みます。 Codex アプリはその共有状態を読み取るため、ルーティングされたモデルは通常の Codex カタログ エントリとしてアプリのモデル ピッカーに表示されます。 -OpenAI エントリには、ネイティブ Codex ログインと、名前空間付きの `openai-apikey/` API キーという 2 つの資格情報ルートがあります。`codexAccountMode` だけを Pool と Direct の間で変更しても、ピッカー ID は変わりません。ただし、`codexAccountNamespaces` に対象アカウントが存在する selector がある場合、opencodex は対応するアカウントごとに `/` 行を追加し、ピッカーでは bare native 行を非表示にします。Selector 名はユーザーが決める公開ラベルであり、組み込みのアカウント role の意味はありません。`selector` 付きの行を選択すると、対応付けられたアカウントだけが使用され、アクティブな Pool アカウントは変更されません。対象を利用できない場合、別のアカウントへ切り替えずにリクエストが失敗します。詳しくは [Codex アカウントの明示的な selector](/reference/configuration/routing/#exact-codex-account-selectors) を参照してください。API GPT-5.6 エントリは 1,050,000 コンテキスト / 922,000 最大入力を使用し、`*-pro` ピッカー ID は `reasoning.mode: "pro"` のベース ワイヤ モデルに解決されますが、ログ、使用状況、およびピッカー状態は仮想 ID を保持します。 API カタログは、`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、およびそれらの 3 つの Pro 仮想 ID の 8 つの ID に固定されています。汎用の `gpt-5.6-pro` エイリアスはありません。コンパクト リクエストは、選択された層を保持しますが、推論オブジェクトなしで基本モデルを送信します。 +OpenAI エントリには、ネイティブ Codex ログインと、名前空間付きの `openai-apikey/` API キーという 2 つの資格情報ルートがあります。`codexAccountMode` だけを Pool と Direct の間で変更しても、ピッカー ID は変わりません。`codexAccountPickerEnabled` が `false` ではなく、`codexAccountNamespaces` に対象アカウントが存在する selector がある場合、opencodex は対応するアカウントごとに `/` 行を追加し、ピッカーでは bare native 行を非表示にします。`false` は生成 row だけを非表示にし、設定済みの exact selector route は引き続き利用でき、対象が利用できなければ別のアカウントへ切り替えず失敗します。Selector 名はユーザーが決める公開ラベルであり、組み込みのアカウント role の意味はありません。`selector` 付きの行を選択すると、対応付けられたアカウントだけが使用され、アクティブな Pool アカウントは変更されません。詳しくは [Codex アカウントの明示的な selector](/reference/configuration/routing/#exact-codex-account-selectors) を参照してください。API GPT-5.6 エントリは 1,050,000 コンテキスト / 922,000 最大入力を使用し、`*-pro` ピッカー ID は `reasoning.mode: "pro"` のベース ワイヤ モデルに解決されますが、ログ、使用状況、およびピッカー状態は仮想 ID を保持します。 API カタログは、`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、およびそれらの 3 つの Pro 仮想 ID の 8 つの ID に固定されています。汎用の `gpt-5.6-pro` エイリアスはありません。コンパクト リクエストは、選択された層を保持しますが、推論オブジェクトなしで基本モデルを送信します。 ピッカー ID で資格情報ルートを明示的に選択します。Pool/Direct は Providers ページで変更します。以下の `` は、`codexAccountNamespaces` で対応付けたユーザー定義の公開ラベルです。 @@ -45,8 +45,8 @@ visibility = "list" |ルート |ピッカー ID とカタログのメタデータ | | --- | --- | -| Codex ログイン (有効な account selector なし) | `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` などの bare native id を表示し、`codexAccountMode` に従って Pool または Direct を使用します。GPT-5.6 行のカタログ ウィンドウは 372,000 トークンです。 | -| Codex ログイン (有効な account selector あり) | 有効な selector とサポート対象 native model の各組み合わせに `/` 行を表示します。各行は対応付けられたアカウントだけを使用し、bare native 行はピッカーで非表示になります。Native metadata と context window は保持されます。 | +| Codex ログイン (picker 非表示または有効な account selector なし) | `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` などの bare native id を表示し、`codexAccountMode` に従って Pool または Direct を使用します。GPT-5.6 行のカタログ ウィンドウは 372,000 トークンです。非表示中も設定済み exact selector route は呼び出せます。 | +| Codex ログイン (picker 表示が有効で account selector あり) | 有効な selector とサポート対象 native model の各組み合わせに `/` 行を表示します。各行は対応付けられたアカウントだけを使用し、bare native 行はピッカーで非表示になります。Native metadata と context window は保持されます。 | | OpenAI (API キー) |正確に 8 つの名前空間行: `gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna、および 3 つの `*-pro` 仮想 ID (コンテキスト 1,050,000、8 つすべての最大入力 922,000) | |オープンルーター | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna` (1,050,000) | |カーソル |静的フォールバックには、`cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、および `cursor/gpt-5.6-luna` (1,000,000)、さらに `cursor/grok-4.5` および `cursor/grok-4.5-fast` (500,000) が含まれます。ライブアカウントの検出により、どれが表示されたままになるかが決まります。 | diff --git a/docs-site/src/content/docs/ja/guides/web-dashboard.md b/docs-site/src/content/docs/ja/guides/web-dashboard.md index 5dd87b4bd..74f0306e0 100644 --- a/docs-site/src/content/docs/ja/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ja/guides/web-dashboard.md @@ -109,7 +109,7 @@ GUI はプロキシの JSON 管理 API を使うシンクライアントです | エンドポイント | 用途 | --- | --- | -| `GET` / `PUT /api/settings` | 設定を読むか Codex 自動起動をオン/オフします。 | +| `GET` / `PUT /api/settings` | 設定を読むか、Codex 自動起動、ストリームモード、アプリ管理のメモリ予算、account-qualified picker の表示を更新します。picker の変更が保存されても catalog refresh が保留の場合は `catalogRefreshPending: true` を返すため、`ocx sync` で再試行してください。 | | `GET /api/startup-health` | 秘密情報を含まないルーティング、サービス、shim、再起動安全性診断を読み取ります。 | | `GET` / `POST /api/windows-tray` | Windows トレイの導入・表示状態を読み取り、`install`、`start`、`stop`、`uninstall` を実行します。 | | `POST /api/sync` | 共有モデルカタログを再構築し Codex モデルキャッシュを古い状態としてマークします。 | diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 7c3ab0288..d98a86101 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -16,7 +16,8 @@ description: プロバイダー エントリ、認証、エンドポイント、 | `contextCapValue?` | `number` | `350000` |ダッシュボードのコンテキストキャップ コントロールで使用される値。これを変更すると、有効になっているすべての `providerContextCaps` エントリが更新されます。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex プール アカウントのメタデータは Codex Auth によって管理されます。秘密は`codex-accounts.json`に別に住んでいます。 | | `pausedCodexAccountIds?` | `string[]` | `[]` |再開するまでプールの選択から除外されるアカウント (一時停止時のメイン `__main__` アカウントを含む)。 | -| `codexAccountNamespaces?` | `Record` | — | 任意の公開 model selector を保存済み Codex アカウント target に対応付ける任意の map。target が存在する各 selector は Codex picker に個別の `/` row を追加し、各 row はそのアカウントだけを使用します。selector が 1 つでも有効な場合、bare native row は picker で非表示になりますが、明示的に無効化されない限り id は引き続き routing でき、raw `/v1/models` にも表示されます。 | +| `codexAccountNamespaces?` | `Record` | — | 任意の公開 model selector を保存済み Codex アカウント target に対応付ける任意の map。生成 picker row が非表示でも、exact `/` routing はこの map を使用します。picker 表示が有効な場合、target が存在する各 selector の個別 row が追加され、各 row はそのアカウントだけを使い、bare native row は picker で非表示になります。bare native id は Pool / Direct routing を維持し、明示的に無効化されない限り raw `/v1/models` にも残ります。 | +| `codexAccountPickerEnabled?` | `boolean` | 推論 | 生成される account-qualified row の表示だけを制御します。未指定の場合、既存の非空 `codexAccountNamespaces` map は互換性のため表示されます。`true` は row の表示を要求し、map が空なら `PUT /api/settings` が privacy-safe な binding を初期化します。`false` は binding を削除せず row を非表示にし、既存 task や保存済み設定の exact route も無効化しません。 | | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | | `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は紐付け済み/未紐付けタスクの次のリクエストを再評価でき、`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は、未紐付けリクエストまたは紐付け済みタスクの次のリクエストを usage の低い適格アカウントへ移せます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 | diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 05b36e898..78295934b 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -87,7 +87,7 @@ Authorization: Bearer | --- | --- | --- | | `GET /api/config` |編集された、管理上安全な構成 DTO を返します。 — | | `PUT /api/config` |フルコンフィグ置換ガードを無効にする | 405;代わりにフォーカスされたエンドポイントを使用してください。 -| `GET, PUT /api/settings` |ランタイム/起動設定の読み取り、または自動起動、ストリーム モード、アプリ所有のメモリ バジェットの更新 | 400 無効または空の更新 | +| `GET, PUT /api/settings` |ランタイム/起動設定の読み取り、または自動起動、ストリーム モード、アプリ所有のメモリ バジェット、account-qualified picker 表示の更新 | 400 無効または空の更新 | | `GET /api/startup-health` |キャッシュされたサービス/シムの起動状態を読み取る | — | | `POST /api/startup-action` |サービスまたは Codex シムをインストールまたは修復する | 400 無効なアクション。 500 アクション失敗 | | `GET, POST /api/windows-tray` | Windows トレイの状態を読み取るか、インストール/起動/停止/アンインストールする | 400 のサポートされていないプラットフォーム/アクション。 500 操作失敗 | @@ -201,7 +201,7 @@ Authorization: Bearer |メソッドとパス |目的 |注目すべきエラー | | --- | --- | --- | -| `GET, POST, DELETE /api/codex-auth/accounts` | Codex アカウントの一覧表示/更新、必要に応じてインポート、削除 | 400 無効な入力。手動インポートは無効にすることができます。 +| `GET, POST, DELETE /api/codex-auth/accounts` | Codex アカウントの一覧表示/更新、必要に応じてインポート、削除。add/delete response には `catalogRefreshPending` が含まれます | 400 無効な入力。手動インポートは無効にすることができます。 | `PUT /api/codex-auth/accounts/alias` |アカウント エイリアスの設定またはクリア | 400 無効なアカウント/エイリアス | | `PUT /api/codex-auth/accounts/pause` | 1 つのアカウントを一時停止または再開する | 400 無効なアカウント/状態。 404 アカウントが見つかりません | | `PUT /api/codex-auth/accounts/pause-exhausted` |クォータを使い果たしたアカウントを一時停止する |ミューテーションロックの失敗は 503 になります | @@ -216,7 +216,13 @@ Authorization: Bearer | `POST /api/codex-auth/login` | Codex のログインまたは再認証を開始する | 400 無効なリクエスト。競合/ビジー ログイン状態 | | `POST /api/codex-auth/login/code` | Codex ログイン フローの手動コードを送信する | 400 無効なフロー/コード | | `POST /api/codex-auth/login/cancel` | Codex ログイン フローをキャンセルする | — | -| `GET /api/codex-auth/login-status` |フローまたはアカウントのログイン状態をポーリングする |不明なフローは `expired` を報告します。アクティブなフローは `idle` を報告しません | +| `GET /api/codex-auth/login-status` |フローまたはアカウントのログイン状態をポーリングする。完了した add に `catalogRefreshPending: true` が含まれる場合があります |不明なフローは `expired` を報告します。アクティブなフローがない場合は `idle` を報告します | + +`PUT /api/settings` は boolean `codexAccountPickerEnabled` を受け付けます。有効にすると、binding が +ない場合に privacy-safe な selector binding を初期化します。無効にしても binding と exact route は +保持されます。settings、account add/delete、login の変更は catalog refresh より先に保存されます。 +2 回の refresh がともに失敗した場合も変更自体は成功し、`catalogRefreshPending: true` を返します。 +`ocx sync` で再試行してください。response と warning は元の failure detail を公開しません。 この委任されたファミリーでの構成ライターまたは資格情報の更新ロックのタイムアウトは、コード `CONFIG_MUTATION_LOCK_UNAVAILABLE` の HTTP 503 を返します。クライアントは、その応答を永久的なアカウント障害として扱うのではなく、すぐに再試行する必要があります。 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 f6c454c5e..8056de531 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 @@ -9,11 +9,13 @@ opencodex는 Codex App을 직접 고치지 않습니다. Codex CLI/TUI가 이미 OpenAI 항목에는 네이티브 Codex 로그인과 네임스페이스가 붙은 `openai-apikey/` API key 경로라는 두 가지 credential 경로가 있습니다. `codexAccountMode`만 Pool과 Direct 사이에서 바꾸는 것은 -선택기 id를 바꾸지 않습니다. 하지만 `codexAccountNamespaces`에 대상 계정이 존재하는 selector가 있으면, -opencodex는 매핑된 계정별로 `/` 행을 추가하고 선택기에서 bare native 행을 -숨깁니다. Selector 이름은 사용자가 정하는 공개 label이며 내장된 계정 역할 의미가 없습니다. `selector`가 -붙은 행을 선택하면 매핑된 계정만 사용하고 활성 Pool 계정은 바뀌지 않습니다. 대상 계정을 사용할 수 없으면 -다른 계정으로 전환하지 않고 요청이 실패합니다. 자세한 내용은 [명시적 Codex 계정 selector](/reference/configuration/routing/#exact-codex-account-selectors)를 +선택기 id를 바꾸지 않습니다. `codexAccountPickerEnabled`가 `false`가 아니고 +`codexAccountNamespaces`에 대상 계정이 존재하는 selector가 있으면, opencodex는 매핑된 계정별로 +`/` 행을 추가하고 선택기에서 bare native 행을 숨깁니다. `false`는 생성된 +행만 숨기며, 설정된 exact selector route는 계속 사용할 수 있고 대상을 사용할 수 없으면 다른 계정으로 +전환하지 않고 실패합니다. Selector 이름은 사용자가 정하는 공개 label이며 내장된 계정 역할 의미가 +없습니다. `selector`가 붙은 행을 선택하면 매핑된 계정만 사용하고 활성 Pool 계정은 바뀌지 않습니다. +자세한 내용은 [명시적 Codex 계정 selector](/reference/configuration/routing/#exact-codex-account-selectors)를 참고하세요. API GPT-5.6 항목은 context 1,050,000 / max input 922,000을 쓰고, `*-pro` picker id는 로그, 사용량, picker 상태에는 가상 id를 유지한 채 wire에서는 base model과 `reasoning.mode: "pro"`로 풀립니다. API 카탈로그는 `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, 그리고 세 개의 @@ -70,8 +72,8 @@ GPT-5.6에만 사용합니다. 오래된 템플릿으로 근사하지 않고 모 | 경로 | 선택기 id와 카탈로그 메타데이터 | | --- | --- | -| Codex 로그인(유효한 account selector 없음) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` 같은 bare native id를 표시하고 `codexAccountMode`에 따라 Pool 또는 Direct를 사용합니다. GPT-5.6 행의 카탈로그 창은 372,000토큰입니다. | -| Codex 로그인(유효한 account selector 있음) | 유효한 selector와 지원되는 native model의 각 조합마다 `/` 행을 표시합니다. 각 행은 매핑된 계정만 사용하며 bare native 행은 선택기에서 숨깁니다. Native metadata와 context window는 보존됩니다. | +| Codex 로그인(picker가 숨겨져 있거나 유효한 account selector 없음) | `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` 같은 bare native id를 표시하고 `codexAccountMode`에 따라 Pool 또는 Direct를 사용합니다. GPT-5.6 행의 카탈로그 창은 372,000토큰입니다. 설정된 exact selector route는 숨겨진 동안에도 호출할 수 있습니다. | +| Codex 로그인(picker가 활성화되고 유효한 account selector 있음) | 유효한 selector와 지원되는 native model의 각 조합마다 `/` 행을 표시합니다. 각 행은 매핑된 계정만 사용하며 bare native 행은 선택기에서 숨깁니다. Native metadata와 context window는 보존됩니다. | | OpenAI(API key) | 정확히 여덟 개의 네임스페이스 행: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna, 그리고 세 개의 `*-pro` 가상 id (모두 컨텍스트 1,050,000; 최대 입력 922,000) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (1,050,000) | | Cursor | 정적 폴백에는 `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra`, `cursor/gpt-5.6-luna` (1,000,000)와 `cursor/grok-4.5`, `cursor/grok-4.5-fast` (500,000)가 들어갑니다. 실시간 계정 탐색이 어떤 항목을 계속 보일지 정합니다. | diff --git a/docs-site/src/content/docs/ko/guides/web-dashboard.md b/docs-site/src/content/docs/ko/guides/web-dashboard.md index aedea3b6c..99487ac06 100644 --- a/docs-site/src/content/docs/ko/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ko/guides/web-dashboard.md @@ -131,7 +131,7 @@ GUI는 프록시의 JSON 관리 API를 사용하는 얇은 클라이언트입니 | 엔드포인트 | 용도 | | --- | --- | -| `GET` / `PUT /api/settings` | 설정을 읽거나 Codex 자동 시작을 켜고 끕니다. | +| `GET` / `PUT /api/settings` | 설정을 읽거나 Codex 자동 시작, stream mode, 앱 관리 memory budget, account-qualified picker 표시를 업데이트합니다. picker 변경이 저장되었지만 catalog refresh가 보류되면 `catalogRefreshPending: true`를 반환하므로 `ocx sync`로 다시 시도하세요. | | `GET` / `POST /api/github/star` | `gh`로 확인한 스타 상태를 읽거나 저장소에 스타를 남깁니다. 대시보드 세션 없이 에이전트가 POST하면 `403` `agent_consent_required`로 거절합니다. | | `GET /api/startup-health` | 비밀값 없이 라우팅, 서비스, shim, 재부팅 안전성 진단을 읽습니다. | | `GET` / `POST /api/windows-tray` | Windows 트레이 설치 및 표시 상태를 읽거나 `install`, `start`, `stop`, `uninstall` 작업을 수행합니다. | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index a389a79fc..3bde84c50 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -16,7 +16,8 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 | `contextCapValue?` | `number` | `350000` | 대시보드의 컨텍스트 상한 컨트롤이 사용하는 값입니다. 이 값을 바꾸면 활성화된 모든 `providerContextCaps` 항목이 함께 갱신됩니다. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth가 관리하는 ChatGPT/Codex 풀 계정 메타데이터입니다. 비밀 정보는 `codex-accounts.json`에 따로 저장됩니다. | | `pausedCodexAccountIds?` | `string[]` | `[]` | 일시 중지된 `__main__` 계정을 포함해, 재개될 때까지 Pool 선택에서 제외되는 계정입니다. | -| `codexAccountNamespaces?` | `Record` | — | 임의의 공개 model selector를 저장된 Codex 계정 target에 연결하는 선택적 map입니다. target이 존재하는 각 selector는 Codex picker에 별도의 `/` row를 추가하며, 각 row는 해당 계정만 사용합니다. selector가 하나라도 활성화되면 bare native row는 picker에서 숨겨지지만, 명시적으로 비활성화하지 않는 한 해당 id는 계속 routing 가능하고 raw `/v1/models`에 표시됩니다. | +| `codexAccountNamespaces?` | `Record` | — | 임의의 공개 model selector를 저장된 Codex 계정 target에 연결하는 선택적 map입니다. 생성된 picker row가 숨겨져도 exact `/` routing은 이 map을 사용합니다. picker 표시가 활성화되면 target이 존재하는 각 selector에 별도 row가 생기고, 각 row는 해당 계정만 사용하며 bare native row는 picker에서 숨겨집니다. bare native id는 Pool / Direct routing을 유지하고, 명시적으로 비활성화하지 않는 한 raw `/v1/models`에도 남습니다. | +| `codexAccountPickerEnabled?` | `boolean` | 추론 | 생성된 account-qualified row만 제어합니다. 생략하면 비어 있지 않은 `codexAccountNamespaces` map은 호환성을 위해 계속 표시됩니다. `true`는 이 row들의 표시를 요청하며, map이 비어 있으면 `PUT /api/settings`가 개인정보를 노출하지 않는 binding을 초기화합니다. `false`는 binding을 삭제하지 않고 row를 숨기며, 기존 task와 저장된 설정의 exact route도 비활성화하지 않습니다. | | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | | `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩된 작업과 바인딩 없는 작업의 다음 요청을 모두 재평가할 수 있고, `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청이나 바인딩된 작업의 다음 요청을 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. | diff --git a/docs-site/src/content/docs/ko/reference/management-api.md b/docs-site/src/content/docs/ko/reference/management-api.md index d5f0f0ecf..9b08bb7ec 100644 --- a/docs-site/src/content/docs/ko/reference/management-api.md +++ b/docs-site/src/content/docs/ko/reference/management-api.md @@ -87,7 +87,7 @@ Authorization: Bearer | --- | --- | --- | | `GET /api/config` | redacted된 management-safe configuration DTO를 반환합니다 | — | | `PUT /api/config` | 전체 구성 교체 방지 기능이 비활성화되어 있습니다 | 405; 대신 집중된 엔드포인트를 사용하십시오 | -| `GET, PUT /api/settings` | 런타임/시작 설정을 읽거나 auto-start, stream mode, 앱 소유 memory budget을 업데이트합니다 | 400 잘못되었거나 비어 있는 업데이트 | +| `GET, PUT /api/settings` | 런타임/시작 설정을 읽거나 auto-start, stream mode, 앱 소유 memory budget, account-qualified picker 표시를 업데이트합니다 | 400 잘못되었거나 비어 있는 업데이트 | | `GET /api/startup-health` | 캐시된 서비스/shim 시작 상태를 읽습니다 | — | | `POST /api/startup-action` | 서비스 또는 Codex shim을 설치하거나 복구합니다 | 400 잘못된 작업; 500 작업 실패 | | `GET, POST /api/windows-tray` | Windows tray 상태를 읽거나 설치, 시작, 중지, 제거합니다 | 400 지원되지 않는 플랫폼/작업; 500 작업 실패 | @@ -201,7 +201,7 @@ Authorization: Bearer | Method and path | 목적 | 주요 오류 | | --- | --- | --- | -| `GET, POST, DELETE /api/codex-auth/accounts` | Codex account를 나열/갱신, 선택적으로 가져오기, 또는 삭제합니다 | 400 잘못된 입력; 수동 가져오기를 비활성화할 수 있음 | +| `GET, POST, DELETE /api/codex-auth/accounts` | Codex account를 나열/갱신, 선택적으로 가져오기, 또는 삭제합니다. add/delete 응답에는 `catalogRefreshPending`이 포함됩니다 | 400 잘못된 입력; 수동 가져오기를 비활성화할 수 있음 | | `PUT /api/codex-auth/accounts/alias` | 계정 alias를 설정하거나 지웁니다 | 400 잘못된 account/alias | | `PUT /api/codex-auth/accounts/pause` | 계정 하나를 일시 중지하거나 재개합니다 | 400 잘못된 account/state; 404 누락된 account | | `PUT /api/codex-auth/accounts/pause-exhausted` | quota가 소진된 account를 일시 중지합니다 | mutation-lock 실패는 503이 됩니다 | @@ -216,7 +216,13 @@ Authorization: Bearer | `POST /api/codex-auth/login` | Codex 로그인 또는 재인증을 시작합니다 | 400 잘못된 요청; 충돌/바쁨 로그인 상태 | | `POST /api/codex-auth/login/code` | Codex 로그인 흐름용 수동 코드를 제출합니다 | 400 잘못된 흐름/code | | `POST /api/codex-auth/login/cancel` | Codex 로그인 흐름을 취소합니다 | — | -| `GET /api/codex-auth/login-status` | 흐름 또는 account 로그인 상태를 조회합니다 | 알 수 없는 흐름은 `expired`로 보고되며, 활성 흐름이 없으면 `idle`로 보고됩니다 | +| `GET /api/codex-auth/login-status` | 흐름 또는 account 로그인 상태를 조회합니다. 완료된 add에 `catalogRefreshPending: true`가 포함될 수 있습니다 | 알 수 없는 흐름은 `expired`로 보고되며, 활성 흐름이 없으면 `idle`로 보고됩니다 | + +`PUT /api/settings`는 boolean `codexAccountPickerEnabled`를 받습니다. 활성화할 때 binding이 없으면 +개인정보를 노출하지 않는 selector binding을 초기화하고, 비활성화할 때는 그 binding과 exact route를 +유지합니다. settings, account add/delete, login 변경은 catalog refresh보다 먼저 저장됩니다. refresh가 +두 번 모두 실패해도 변경은 성공하고 `catalogRefreshPending: true`를 반환합니다. `ocx sync`로 다시 +시도하세요. 응답과 경고는 원래의 실패 세부 정보를 노출하지 않습니다. 이 위임된 계열에서 configuration-writer 또는 credential-refresh lock timeout이 발생하면 HTTP 503과 `CONFIG_MUTATION_LOCK_UNAVAILABLE` 코드가 반환됩니다. 클라이언트는 이를 영구적인 계정 실패로 보지 말고 곧바로 다시 시도해야 합니다. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a9603702b..72377275e 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -17,7 +17,8 @@ authenticated. | `contextCapValue?` | `number` | `350000` | Value used by the dashboard context-cap controls; changing it updates every enabled `providerContextCaps` entry. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. | -| `codexAccountNamespaces?` | `Record` | — | Optional map from an arbitrary public model selector to a stored Codex account target. Each selector whose target is present adds separate `/` rows to the Codex picker; each row uses only that account. With any selector active, bare native rows are hidden in the picker, but their ids remain routable and listed by raw `/v1/models` unless explicitly disabled. | +| `codexAccountNamespaces?` | `Record` | — | Optional map from an arbitrary public model selector to a stored Codex account target. Exact `/` routing uses this map even when its generated picker rows are hidden. When picker visibility is enabled, each selector whose target is present adds separate rows that use only that account, and bare native rows are hidden from the picker. Bare native ids retain Pool/Direct routing and remain listed by raw `/v1/models` unless explicitly disabled. | +| `codexAccountPickerEnabled?` | `boolean` | inferred | Controls generated account-qualified rows only. When omitted, a non-empty `codexAccountNamespaces` map remains visible for compatibility. `true` requests those rows; `PUT /api/settings` initializes privacy-safe bindings when the map is empty. `false` hides the rows without deleting bindings or disabling exact routes in existing tasks and saved settings. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account for the next request. Selection clears thread affinity; in-flight requests keep captured credentials. | | `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate both bound and unbound tasks on their next request; `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or proactively rebind a bound task to a lower-usage eligible account. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. | diff --git a/docs-site/src/content/docs/reference/management-api.md b/docs-site/src/content/docs/reference/management-api.md index 586466ccf..b6e6a62f0 100644 --- a/docs-site/src/content/docs/reference/management-api.md +++ b/docs-site/src/content/docs/reference/management-api.md @@ -102,7 +102,7 @@ See [Combos](/guides/combos/) for target strategies, cooldowns, aliases, and rou | --- | --- | --- | | `GET /api/config` | Return the redacted, management-safe configuration DTO | — | | `PUT /api/config` | Disabled full-config replacement guard | 405; use focused endpoints instead | -| `GET, PUT /api/settings` | Read runtime/startup settings or update auto-start, stream mode, and app-owned memory budget | 400 invalid or empty update | +| `GET, PUT /api/settings` | Read runtime/startup settings or update auto-start, stream mode, app-owned memory budget, and account-qualified picker visibility | 400 invalid or empty update | | `GET /api/startup-health` | Read cached service/shim startup health | — | | `POST /api/startup-action` | Install or repair the service or Codex shim | 400 invalid action; 500 action failure | | `GET, POST /api/windows-tray` | Read Windows tray state or install/start/stop/uninstall it | 400 unsupported platform/action; 500 operation failure | @@ -222,7 +222,7 @@ manager. Its routes are: | Method and path | Purpose | Notable errors | | --- | --- | --- | -| `GET, POST, DELETE /api/codex-auth/accounts` | List/refresh, optionally import, or delete Codex accounts | 400 invalid input; manual import can be disabled | +| `GET, POST, DELETE /api/codex-auth/accounts` | List/refresh, optionally import, or delete Codex accounts; add/delete responses include `catalogRefreshPending` | 400 invalid input; manual import can be disabled | | `PUT /api/codex-auth/accounts/alias` | Set or clear an account alias | 400 invalid account/alias | | `PUT /api/codex-auth/accounts/pause` | Pause or resume one account | 400 invalid account/state; 404 missing account | | `PUT /api/codex-auth/accounts/pause-exhausted` | Pause accounts whose quota is exhausted | Mutation-lock failures become 503 | @@ -237,7 +237,13 @@ manager. Its routes are: | `POST /api/codex-auth/login` | Start Codex login or reauthentication | 400 invalid request; conflict/busy login states | | `POST /api/codex-auth/login/code` | Submit a manual code for a Codex login flow | 400 invalid flow/code | | `POST /api/codex-auth/login/cancel` | Cancel a Codex login flow | — | -| `GET /api/codex-auth/login-status` | Poll a flow or account login state | Unknown flows report `expired`; no active flow reports `idle` | +| `GET /api/codex-auth/login-status` | Poll a flow or account login state; a completed add can include `catalogRefreshPending: true` | Unknown flows report `expired`; no active flow reports `idle` | + +`PUT /api/settings` accepts boolean `codexAccountPickerEnabled`. Enabling it initializes +privacy-safe selector bindings when none exist; disabling it keeps those bindings and exact routes. +Settings, account-add, account-delete, and login mutations are persisted before catalog refresh. +If both refresh attempts fail, the mutation still returns success with `catalogRefreshPending: true`; +run `ocx sync` to retry. The response and warning do not expose the underlying failure detail. Configuration-writer or credential-refresh lock timeouts under this delegated family return HTTP 503 with code `CONFIG_MUTATION_LOCK_UNAVAILABLE`. Clients should retry shortly rather than treating 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 1fad62d41..48b393be3 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 @@ -9,12 +9,13 @@ opencodex не патчит Codex App. Он записывает ту же ко Записи OpenAI используют два credential-транспорта: нативный вход Codex и namespaced-транспорт API-ключа `openai-apikey/`. Само по себе переключение `codexAccountMode` между Pool и Direct -не меняет id в picker'е. Однако если в `codexAccountNamespaces` есть подходящие селекторы, -opencodex добавляет для сопоставленных аккаунтов отдельные строки -`/` и скрывает bare native-строки из picker'а. Имена селекторов — -это публичные метки, которые выбирает пользователь; встроенного смысла роли аккаунта у них нет. -Выбор строки с селектором использует только сопоставленный аккаунт, не меняет активный аккаунт Pool -и при недоступности цели завершается ошибкой без переключения на другой аккаунт. Подробнее см. +не меняет id в picker'е. Если `codexAccountPickerEnabled` не равен `false`, а в +`codexAccountNamespaces` есть подходящие селекторы, opencodex добавляет для сопоставленных аккаунтов +отдельные строки `/` и скрывает bare native-строки из picker'а. Значение +`false` скрывает только созданные строки; настроенные exact selector route остаются доступными и при +недоступности цели завершаются ошибкой без переключения аккаунта. Имена селекторов — это публичные метки, +которые выбирает пользователь; встроенного смысла роли аккаунта у них нет. Выбор строки с селектором использует +только сопоставленный аккаунт и не меняет активный аккаунт Pool. Подробнее см. в разделе [Точные селекторы аккаунтов Codex](/reference/configuration/routing/#exact-codex-account-selectors). У строк API GPT-5.6 — контекст 1,050,000 и максимум входа 922,000; id picker'а вида `*-pro` разрешаются в @@ -75,8 +76,8 @@ per-model identity и метаданные вместо приближения | Маршрут | Id в селекторе и метаданные каталога | | --- | --- | -| Вход Codex (без подходящих селекторов аккаунтов) | Bare native-id, например `gpt-5.6-sol`, `gpt-5.6-terra` и `gpt-5.6-luna`; Pool или Direct выбирается через `codexAccountMode`. У строк GPT-5.6 окно каталога 372 000 токенов. | -| Вход Codex (с подходящими селекторами аккаунтов) | По одной строке `/` для каждой пары подходящего селектора и поддерживаемой нативной модели; каждая строка использует только сопоставленный аккаунт, а bare native-строки скрыты из picker'а. Нативные метаданные и окна контекста сохраняются. | +| Вход Codex (picker скрыт или нет подходящих селекторов аккаунтов) | Bare native-id, например `gpt-5.6-sol`, `gpt-5.6-terra` и `gpt-5.6-luna`; Pool или Direct выбирается через `codexAccountMode`. У строк GPT-5.6 окно каталога 372 000 токенов. Настроенные exact selector route остаются доступными в скрытом состоянии. | +| Вход Codex (picker включён и есть подходящие селекторы аккаунтов) | По одной строке `/` для каждой пары подходящего селектора и поддерживаемой нативной модели; каждая строка использует только сопоставленный аккаунт, а bare native-строки скрыты из picker'а. Нативные метаданные и окна контекста сохраняются. | | OpenAI (API key) | Ровно восемь namespaced-строк: `gpt-5.5`, `gpt-5.6`, Sol/Terra/Luna и три виртуальных id `*-pro` (контекст 1,050,000; максимум входа 922,000 у всех восьми) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`, `openrouter/openai/gpt-5.6-terra`, `openrouter/openai/gpt-5.6-luna` (1,050,000) | | Cursor | Статический fallback включает `cursor/gpt-5.6-sol`, `cursor/gpt-5.6-terra` и `cursor/gpt-5.6-luna` (1,000,000), а также `cursor/grok-4.5` и `cursor/grok-4.5-fast` (500,000); какие из них останутся видимыми, решает live-discovery аккаунта. | diff --git a/docs-site/src/content/docs/ru/guides/web-dashboard.md b/docs-site/src/content/docs/ru/guides/web-dashboard.md index bb825211c..47d1d2b77 100644 --- a/docs-site/src/content/docs/ru/guides/web-dashboard.md +++ b/docs-site/src/content/docs/ru/guides/web-dashboard.md @@ -116,7 +116,7 @@ GUI — это тонкий клиент поверх JSON-API управлен | Эндпоинт | Назначение | | --- | --- | -| `GET` / `PUT /api/settings` | Чтение настроек или переключение автозапуска Codex. | +| `GET` / `PUT /api/settings` | Чтение настроек или обновление автозапуска Codex, stream mode, app-owned memory budget и видимости account-qualified picker. Если изменение picker сохранено, но catalog refresh ещё ожидается, ответ содержит `catalogRefreshPending: true`; повторите через `ocx sync`. | | `GET /api/startup-health` | Чтение безопасной диагностики маршрутизации, службы, shim и устойчивости к перезагрузке. | | `GET` / `POST /api/windows-tray` | Чтение или изменение установки и видимости трея Windows; POST поддерживает `install`, `start`, `stop`, `uninstall`. | | `POST /api/sync` | Пересборка общего каталога моделей и инвалидация кэша моделей Codex. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index d2bb183b1..a291c0355 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -17,7 +17,8 @@ description: Записи провайдеров, аутентификация, | `contextCapValue?` | `number` | `350000` | Значение, используемое элементами управления context-cap в дашборде; его изменение обновляет все включённые записи `providerContextCaps`. | | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, которыми управляет Codex Auth. Секреты живут отдельно в `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Аккаунты, исключённые из выбора Pool до снятия паузы, включая основной аккаунт `__main__`, если он поставлен на паузу. | -| `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление произвольного публичного селектора модели с сохранённым аккаунтом Codex. Каждый селектор с существующей целью добавляет в model picker Codex отдельные строки `/`; каждая строка использует только этот аккаунт. Если активен хотя бы один селектор, bare native-строки скрываются в picker, но их id остаются маршрутизируемыми и перечисляются raw `/v1/models`, если они не отключены явно. | +| `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление произвольного публичного селектора модели с сохранённым аккаунтом Codex. Exact routing `/` использует эту map, даже если созданные picker-row скрыты. Когда видимость picker включена, каждый selector с существующей целью добавляет отдельные row, каждая из которых использует только сопоставленный аккаунт, а bare native-row скрываются в picker. Bare native-id сохраняют Pool / Direct routing и остаются в raw `/v1/models`, если не отключены явно. | +| `codexAccountPickerEnabled?` | `boolean` | выводится | Управляет только созданными account-qualified row. Если поле опущено, непустая map `codexAccountNamespaces` остаётся видимой для совместимости. `true` запрашивает эти row; если map пуста, `PUT /api/settings` создаёт privacy-safe binding. `false` скрывает row, но не удаляет binding и не отключает exact route в существующих task и сохранённых настройках. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | | `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий запрос как привязанной, так и непривязанной задачи; `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос или следующий запрос привязанной задачи на подходящий аккаунт с меньшим usage. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. | diff --git a/docs-site/src/content/docs/ru/reference/management-api.md b/docs-site/src/content/docs/ru/reference/management-api.md index 4b0b962d8..8344b09d7 100644 --- a/docs-site/src/content/docs/ru/reference/management-api.md +++ b/docs-site/src/content/docs/ru/reference/management-api.md @@ -103,7 +103,7 @@ GUI-сессия в стиле loopback не выпускается. | --- | --- | --- | | `GET /api/config` | Вернуть redacted DTO конфигурации, безопасный для management API | — | | `PUT /api/config` | Отключённая защита от полной замены конфигурации | 405; используйте вместо этого узкие endpoint'ы | -| `GET, PUT /api/settings` | Прочитать runtime/startup setting'и или обновить auto-start, stream mode и budget app-owned memory | 400 invalid or empty update | +| `GET, PUT /api/settings` | Прочитать runtime/startup setting'и или обновить auto-start, stream mode, budget app-owned memory и видимость account-qualified picker | 400 invalid or empty update | | `GET /api/startup-health` | Прочитать кэшированное startup health службы/shim'а | — | | `POST /api/startup-action` | Установить или починить службу или Codex shim | 400 invalid action; 500 action failure | | `GET, POST /api/windows-tray` | Прочитать состояние Windows tray или установить/запустить/остановить/удалить её | 400 unsupported platform/action; 500 operation failure | @@ -224,7 +224,7 @@ Management-аутентификация доказывает доступ к п | Метод и путь | Назначение | Особые ошибки | | --- | --- | --- | -| `GET, POST, DELETE /api/codex-auth/accounts` | Показать/обновить список, по желанию импортировать, либо удалить аккаунты Codex | 400 invalid input; manual import can be disabled | +| `GET, POST, DELETE /api/codex-auth/accounts` | Показать/обновить список, по желанию импортировать, либо удалить аккаунты Codex; response add/delete содержат `catalogRefreshPending` | 400 invalid input; manual import can be disabled | | `PUT /api/codex-auth/accounts/alias` | Задать или очистить alias аккаунта | 400 invalid account/alias | | `PUT /api/codex-auth/accounts/pause` | Поставить один аккаунт на паузу или снять её | 400 invalid account/state; 404 missing account | | `PUT /api/codex-auth/accounts/pause-exhausted` | Поставить на паузу аккаунты с исчерпанной квотой | Сбои mutation-lock превращаются в 503 | @@ -239,7 +239,13 @@ Management-аутентификация доказывает доступ к п | `POST /api/codex-auth/login` | Запустить login или reauthentication для Codex | 400 invalid request; conflict/busy login states | | `POST /api/codex-auth/login/code` | Отправить manual code для login-flow Codex | 400 invalid flow/code | | `POST /api/codex-auth/login/cancel` | Отменить login-flow Codex | — | -| `GET /api/codex-auth/login-status` | Опрашивать flow или login-state аккаунта | Неизвестные flow'ы сообщаются как `expired`; отсутствие активного flow — как `idle` | +| `GET /api/codex-auth/login-status` | Опрашивать flow или login-state аккаунта; завершённый add может содержать `catalogRefreshPending: true` | Неизвестные flow'ы сообщаются как `expired`; отсутствие активного flow — как `idle` | + +`PUT /api/settings` принимает boolean `codexAccountPickerEnabled`. При включении, если binding ещё нет, +он создаёт privacy-safe selector binding; при отключении binding и exact route сохраняются. Изменения +settings, account add/delete и login сохраняются до catalog refresh. Если обе попытки refresh неудачны, +изменение всё равно завершается успешно с `catalogRefreshPending: true`; для повтора выполните `ocx sync`. +Response и warning не раскрывают исходные failure detail. Если внутри этого delegated family writer конфигурации или refresh credential'ов не получает lock в разумное время, возвращается HTTP 503 с кодом `CONFIG_MUTATION_LOCK_UNAVAILABLE`. Клиенту нужно 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 2e9eaad16..1e717a141 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 @@ -5,7 +5,7 @@ description: opencodex 中的模型如何通过共享 Codex 目录出现在 Code opencodex 不会修改 Codex App。它会写入 Codex CLI/TUI 已经使用的同一套 Codex 配置和模型目录。因为 Codex App 读取的是这份共享状态,路由模型可以像普通 Codex 目录条目一样出现在 App 的模型选择器中。 -OpenAI 条目有两种凭据通道:原生 Codex 登录,以及命名空间化的 `openai-apikey/` API key 通道。仅在 Pool 与 Direct 之间切换 `codexAccountMode` 不会改变选择器 id。但当 `codexAccountNamespaces` 中有目标账户存在的 selector 时,opencodex 会为映射账户添加独立的 `/` 行,并在选择器中隐藏裸原生行。Selector 名称是用户自定义的公开标签,没有内置的账户角色含义。选择带 `selector` 的行只会使用映射账户,不会更改当前 Pool 账户;目标不可用时,请求会直接失败,不会切换到其他账户。详情请参阅[精确 Codex 账户选择器](/reference/configuration/routing/#exact-codex-account-selectors)。API GPT-5.6 条目使用 1,050,000 context / 922,000 max input,而 `*-pro` 选择器 id 会解析到基础线协议模型,并在日志、用量和选择器状态中保留虚拟 id,同时带上 `reasoning.mode: "pro"`。API 目录固定为恰好八个 id:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna,以及它们三个 Pro 虚拟 id;不存在通用的 `gpt-5.6-pro` 别名。Compact 请求会保留所选 tier,但发送基础模型且不带 reasoning 对象。 +OpenAI 条目有两种凭据通道:原生 Codex 登录,以及命名空间化的 `openai-apikey/` API key 通道。仅在 Pool 与 Direct 之间切换 `codexAccountMode` 不会改变选择器 id。当 `codexAccountPickerEnabled` 不为 `false`,且 `codexAccountNamespaces` 中有目标账户存在的 selector 时,opencodex 会为映射账户添加独立的 `/` 行,并在选择器中隐藏裸原生行。`false` 只会隐藏生成的行;已配置的 exact selector route 仍可使用,目标不可用时仍会直接失败,不会切换账户。Selector 名称是用户自定义的公开标签,没有内置的账户角色含义。选择带 `selector` 的行只会使用映射账户,不会更改当前 Pool 账户。详情请参阅[精确 Codex 账户选择器](/reference/configuration/routing/#exact-codex-account-selectors)。API GPT-5.6 条目使用 1,050,000 context / 922,000 max input,而 `*-pro` 选择器 id 会解析到基础线协议模型,并在日志、用量和选择器状态中保留虚拟 id,同时带上 `reasoning.mode: "pro"`。API 目录固定为恰好八个 id:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna,以及它们三个 Pro 虚拟 id;不存在通用的 `gpt-5.6-pro` 别名。Compact 请求会保留所选 tier,但发送基础模型且不带 reasoning 对象。 请通过选择器 id 显式选择凭据路径。在 Providers 页面切换 Pool/Direct;下面的 `` 是 用户自定义、通过 `codexAccountNamespaces` 映射的公开标签: @@ -46,8 +46,8 @@ visibility = "list" | 路由 | 选择器 id 与目录元数据 | | --- | --- | -| Codex 登录(没有有效账户 selector) | 显示 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 等裸原生 id,并按 `codexAccountMode` 使用 Pool 或 Direct。GPT-5.6 行使用 372,000-token 目录窗口。 | -| Codex 登录(有有效账户 selector) | 为每个有效 selector 与受支持原生模型的组合显示 `/` 行。每行只使用映射账户,裸原生行会从选择器中隐藏。原生 metadata 与 context window 会保留。 | +| Codex 登录(picker 已隐藏或没有有效账户 selector) | 显示 `gpt-5.6-sol`、`gpt-5.6-terra`、`gpt-5.6-luna` 等裸原生 id,并按 `codexAccountMode` 使用 Pool 或 Direct。GPT-5.6 行使用 372,000-token 目录窗口。已配置的 exact selector route 在隐藏时仍可调用。 | +| Codex 登录(picker 已启用且有有效账户 selector) | 为每个有效 selector 与受支持原生模型的组合显示 `/` 行。每行只使用映射账户,裸原生行会从选择器中隐藏。原生 metadata 与 context window 会保留。 | | OpenAI(API key) | 恰好八个命名空间行:`gpt-5.5`、`gpt-5.6`、Sol/Terra/Luna,以及三个 `*-pro` 虚拟 id(八个条目均为 1,050,000 context / 922,000 max input) | | OpenRouter | `openrouter/openai/gpt-5.6-sol`、`openrouter/openai/gpt-5.6-terra`、`openrouter/openai/gpt-5.6-luna`(1,050,000) | | Cursor | 静态回退包含 `cursor/gpt-5.6-sol`、`cursor/gpt-5.6-terra`、`cursor/gpt-5.6-luna`(1,000,000),以及 `cursor/grok-4.5` 和 `cursor/grok-4.5-fast`(500,000);实时账户发现会决定最终哪些条目仍然可见。 | diff --git a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md index 4a3eb5505..6466b794e 100644 --- a/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md +++ b/docs-site/src/content/docs/zh-cn/guides/web-dashboard.md @@ -103,7 +103,7 @@ GUI 是代理 JSON 管理 API 之上的轻量客户端。常用 endpoint 包括 | Endpoint | 用途 | | --- | --- | -| `GET` / `PUT /api/settings` | 读取设置或切换 Codex 自动启动。 | +| `GET` / `PUT /api/settings` | 读取设置,或更新 Codex 自动启动、流模式、应用管理的内存预算和 account-qualified picker 可见性。picker 变更已持久化但 catalog refresh 仍待处理时会返回 `catalogRefreshPending: true`;请运行 `ocx sync` 重试。 | | `GET /api/startup-health` | 读取不含秘密信息的路由、服务、shim 和重启安全诊断。 | | `GET` / `POST /api/windows-tray` | 读取或更改 Windows 托盘安装和显示状态;POST 支持 `install`、`start`、`stop`、`uninstall`。 | | `POST /api/sync` | 重建共享模型目录,并把 Codex 模型缓存标记为过期。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index b400ffbd4..9698a3bef 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -16,7 +16,8 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 | `contextCapValue?` | `number` | `350000` | 仪表板上下文上限控件使用的值;修改它会更新所有已启用的 `providerContextCaps` 条目。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池账户元数据。密钥单独存放在 `codex-accounts.json` 中。 | | `pausedCodexAccountIds?` | `string[]` | `[]` | 在恢复之前从 Pool 选择中排除的账户,包括被暂停时的主 `__main__` 账户。 | -| `codexAccountNamespaces?` | `Record` | — | 将任意公开 model selector 映射到已保存 Codex account target 的可选配置。target 存在的每个 selector 都会在 Codex picker 中添加独立的 `/` row,且每个 row 只使用对应账户。只要有 selector 生效,bare native row 就会在 picker 中隐藏;但除非显式禁用,其 id 仍可路由,并继续列在 raw `/v1/models` 中。 | +| `codexAccountNamespaces?` | `Record` | — | 将任意公开 model selector 映射到已保存 Codex account target 的可选配置。即使生成的 picker row 被隐藏,exact `/` routing 仍会使用该 map。启用 picker 可见性时,target 存在的每个 selector 都会添加独立 row,每个 row 只使用对应账户,bare native row 则会在 picker 中隐藏。bare native id 保留 Pool / Direct routing,除非显式禁用,仍会列在 raw `/v1/models` 中。 | +| `codexAccountPickerEnabled?` | `boolean` | 推断 | 仅控制生成的 account-qualified row。省略时,非空 `codexAccountNamespaces` map 为了兼容性仍会显示。`true` 表示要显示这些 row;如果 map 为空,`PUT /api/settings` 会初始化隐私安全的 binding。`false` 会隐藏这些 row,但不会删除 binding,也不会禁用已有 task 和已保存设置的 exact route。 | | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | | `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估已绑定和未绑定任务;`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求或已绑定任务的下一次请求切换到 usage 更低的合格账号。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 | diff --git a/docs-site/src/content/docs/zh-cn/reference/management-api.md b/docs-site/src/content/docs/zh-cn/reference/management-api.md index 704515d2c..fb7cf5867 100644 --- a/docs-site/src/content/docs/zh-cn/reference/management-api.md +++ b/docs-site/src/content/docs/zh-cn/reference/management-api.md @@ -87,7 +87,7 @@ Authorization: Bearer | --- | --- | --- | | `GET /api/config` | 返回已脱敏、对管理安全的配置 DTO | — | | `PUT /api/config` | 禁用的完整配置替换保护 | 405;请改用聚焦端点 | -| `GET, PUT /api/settings` | 读取运行时/启动设置,或更新自动启动、流模式和应用拥有的内存预算 | 400 无效或空更新 | +| `GET, PUT /api/settings` | 读取运行时/启动设置,或更新自动启动、流模式、应用拥有的内存预算和 account-qualified picker 可见性 | 400 无效或空更新 | | `GET /api/startup-health` | 读取缓存的服务/shim 启动健康状态 | — | | `POST /api/startup-action` | 安装或修复服务或 Codex shim | 400 无效动作;500 动作失败 | | `GET, POST /api/windows-tray` | 读取 Windows 托盘状态,或安装、启动、停止、卸载它 | 400 不支持的平台/动作;500 操作失败 | @@ -201,7 +201,7 @@ Authorization: Bearer | 方法和路径 | 用途 | 典型错误 | | --- | --- | --- | -| `GET, POST, DELETE /api/codex-auth/accounts` | 列出/刷新,可选导入,或删除 Codex 账户 | 400 输入无效;手动导入可能被禁用 | +| `GET, POST, DELETE /api/codex-auth/accounts` | 列出/刷新,可选导入,或删除 Codex 账户;add/delete 响应包含 `catalogRefreshPending` | 400 输入无效;手动导入可能被禁用 | | `PUT /api/codex-auth/accounts/alias` | 设置或清除账户别名 | 400 账户/别名无效 | | `PUT /api/codex-auth/accounts/pause` | 暂停或恢复一个账户 | 400 账户/状态无效;404 缺少账户 | | `PUT /api/codex-auth/accounts/pause-exhausted` | 暂停配额已耗尽的账户 | 变更锁失败会变成 503 | @@ -216,7 +216,12 @@ Authorization: Bearer | `POST /api/codex-auth/login` | 启动 Codex 登录或重新认证 | 400 请求无效;登录状态冲突/忙碌 | | `POST /api/codex-auth/login/code` | 为 Codex 登录流程提交手动代码 | 400 流程/代码无效 | | `POST /api/codex-auth/login/cancel` | 取消一个 Codex 登录流程 | — | -| `GET /api/codex-auth/login-status` | 轮询某个流程或账户登录状态 | 未知流程报告为 `expired`;没有活跃流程时报告为 `idle` | +| `GET /api/codex-auth/login-status` | 轮询某个流程或账户登录状态;已完成的 add 可能包含 `catalogRefreshPending: true` | 未知流程报告为 `expired`;没有活跃流程时报告为 `idle` | + +`PUT /api/settings` 接受布尔值 `codexAccountPickerEnabled`。启用时,如果尚无 binding,它会初始化 +隐私安全的 selector binding;禁用时会保留这些 binding 和 exact route。settings、account add/delete +与 login 变更都会在 catalog refresh 之前持久化。如果两次 refresh 均失败,变更仍成功,并返回 +`catalogRefreshPending: true`;运行 `ocx sync` 重试。响应和警告不会暴露底层失败详情。 此委托家族下的配置写入器或凭证刷新锁超时,会返回 HTTP 503,代码为 `CONFIG_MUTATION_LOCK_UNAVAILABLE`。客户端应稍后重试,而不是把该响应视为永久性的账户失败。 diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 795b47103..5b1f5c0f1 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -18,7 +18,10 @@ import { appendDefaultCodexAccountNamespace, codexAccountPickerIsEnabled, } from "./account-namespaces"; -import { refreshCodexCatalogWithRetry } from "./catalog-refresh-status"; +import { + assertCodexCatalogRefreshComplete, + refreshCodexCatalogWithRetry, +} from "./catalog-refresh-status"; import { isCodexAccountPaused, setCodexAccountPaused } from "./account-pause"; import { claimDueCodexQuotaRecoveryProbes, @@ -428,7 +431,7 @@ async function refreshAccountNamespaceCatalog(config: OcxConfig, changed: boolea if (!changed || !codexAccountPickerIsEnabled(config)) return false; return refreshCodexCatalogWithRetry(async () => { const { refreshCodexModelCatalog } = await import("./refresh"); - await refreshCodexModelCatalog(config); + assertCodexCatalogRefreshComplete(await refreshCodexModelCatalog(config)); }); } diff --git a/src/codex/catalog-refresh-status.ts b/src/codex/catalog-refresh-status.ts index 7afd7e2e9..539696cf7 100644 --- a/src/codex/catalog-refresh-status.ts +++ b/src/codex/catalog-refresh-status.ts @@ -1,3 +1,20 @@ +/** Treat an incomplete catalog rewrite or cache invalidation as a refresh failure. */ +export function assertCodexCatalogRefreshComplete( + result: void | { + catalogExists: boolean; + catalogWritten?: boolean; + cacheSynced?: boolean; + }, +): void { + if ( + result?.catalogExists === false + || result?.catalogWritten === false + || result?.cacheSynced === false + ) { + throw new Error("Codex catalog was not refreshed"); + } +} + /** * Refresh the Codex catalog, retrying once after a failure. * diff --git a/src/server/management-api.ts b/src/server/management-api.ts index 9323f23d6..01b272619 100644 --- a/src/server/management-api.ts +++ b/src/server/management-api.ts @@ -74,6 +74,7 @@ export type { ManagementApiDeps } from "./management/context"; import { fetchAllModels } from "./management/shared"; import { CatalogGatherBusyError } from "../codex/catalog/provider-fetch"; import { managementBodyTooLargeResponse } from "./management/body"; +import { assertCodexCatalogRefreshComplete } from "../codex/catalog-refresh-status"; // installed npm version instead of a stale hardcode. export const VERSION = (() => { @@ -103,15 +104,18 @@ export async function handleManagementAPI( } } async function refreshCodexCatalogStrict(): Promise { - if (deps.refreshCodexCatalog) return deps.refreshCodexCatalog(); + if (deps.refreshCodexCatalog) { + assertCodexCatalogRefreshComplete(await deps.refreshCodexCatalog()); + return; + } const { refreshCodexModelCatalog } = await import("../codex/refresh"); - await refreshCodexModelCatalog(config); + assertCodexCatalogRefreshComplete(await refreshCodexModelCatalog(config)); } async function refreshCodexCatalogBestEffort(): Promise { // Preserve the dependency seam's historical behavior: injected failures // remain observable to route tests, while production discovery is best-effort. - if (deps.refreshCodexCatalog) return deps.refreshCodexCatalog(); + if (deps.refreshCodexCatalog) return refreshCodexCatalogStrict(); try { await refreshCodexCatalogStrict(); } catch { diff --git a/src/server/management/context.ts b/src/server/management/context.ts index a00fe26cb..765202c75 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -9,7 +9,11 @@ import type { RuntimePortState } from "../../config"; export interface ManagementApiDeps { toggleCodexMultiAgentV2?: (enabled: boolean) => void; toggleDefaultModeRequestUserInput?: (enabled: boolean) => void; - refreshCodexCatalog?: () => Promise; + refreshCodexCatalog?: () => Promise; /** * Persistence seam for route-level tests. Production leaves this unset and uses * `saveConfigPreservingClaudeCode`; tests that pass an in-memory fixture config diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 65083ee46..fb94bd238 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -2307,8 +2307,8 @@ describe("codex-auth API", () => { expect(getCodexAccountCredential(accountId)).not.toBeNull(); if (pending) throw new Error("private refresh details"); return { - added: 1, path: "catalog.json", catalogExists: false, catalogWritten: false, - cacheSynced: false, comboOmissions: [], + added: 1, path: "catalog.json", catalogExists: true, catalogWritten: true, + cacheSynced: true, comboOmissions: [], }; }); const warnSpy = spyOn(console, "warn").mockImplementation(() => {}); @@ -2338,6 +2338,43 @@ describe("codex-auth API", () => { } }); + test.each([ + ["missing", { catalogExists: false, catalogWritten: false, cacheSynced: false }], + ["unwritten", { catalogExists: true, catalogWritten: false, cacheSynced: false }], + ["cache-unsynced", { catalogExists: true, catalogWritten: true, cacheSynced: false }], + ] as const)("UI-managed manual add treats a non-throwing %s catalog as pending", async (state, result) => { + enableManualImport(); + mockCodexWarmupSuccess(); + const accountId = `manual-picker-${state}-catalog`; + const config = makeConfig({ + codexAccountNamespaces: { desktop: "@main" }, + codexAccountPickerEnabled: true, + }); + const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog").mockResolvedValue({ + added: 0, + path: "catalog.json", + ...result, + comboOmissions: [], + }); + 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: accountId })), + }); + const response = await handleCodexAuthAPI(req, new URL(req.url), config); + + expect(response!.status).toBe(200); + expect(await response!.json()).toEqual({ ok: true, catalogRefreshPending: true }); + expect(refreshSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("ocx sync")); + } finally { + warnSpy.mockRestore(); + refreshSpy.mockRestore(); + } + }); + test("manual maps stay manual while a disabled UI-managed map still tracks new accounts", async () => { enableManualImport(); mockCodexWarmupSuccess(); @@ -3078,8 +3115,8 @@ describe("codex-auth API", () => { expect(persisted.codexAccountNamespaces).toEqual({ team: accountId }); expect(getCodexAccountCredential(accountId)).toBeNull(); return { - added: 0, path: "catalog.json", catalogExists: false, catalogWritten: false, - cacheSynced: false, comboOmissions: [], + added: 0, path: "catalog.json", catalogExists: true, catalogWritten: true, + cacheSynced: true, comboOmissions: [], }; }); try { diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index 717a2d303..bd65c7b91 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -357,10 +357,12 @@ describe("PUT /api/settings", () => { }); expect(response!.status).toBe(200); - expect(await response!.json()).toMatchObject({ + const payload = await response!.json(); + expect(payload).toMatchObject({ codexAccountPickerEnabled: true, catalogRefreshPending: true, }); + expect(JSON.stringify(payload)).not.toContain("private refresh failure detail"); expect(refreshes).toBe(2); const warningText = warning.mock.calls.flat().join(" "); expect(warningText).toContain("ocx sync"); @@ -370,6 +372,39 @@ describe("PUT /api/settings", () => { } }); + test.each([ + ["missing", { catalogExists: false }], + ["unwritten", { catalogExists: true, catalogWritten: false }], + ["cache-unsynced", { catalogExists: true, catalogWritten: true, cacheSynced: false }], + ] as const)("account-picker treats a non-throwing %s catalog as pending", async (_state, result) => { + const warning = spyOn(console, "warn").mockImplementation(() => {}); + const config = { + ...baseConfig(), + codexAccountNamespaces: { main: "@main" }, + codexAccountPickerEnabled: false, + }; + let refreshes = 0; + try { + const response = await putSettings(config, { codexAccountPickerEnabled: true }, { + saveConfigPreservingClaudeCode: () => {}, + refreshCodexCatalog: async () => { + refreshes += 1; + return result; + }, + }); + + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ + codexAccountPickerEnabled: true, + catalogRefreshPending: true, + }); + expect(refreshes).toBe(2); + expect(warning).toHaveBeenCalledWith(expect.stringContaining("ocx sync")); + } finally { + warning.mockRestore(); + } + }); + test("account-picker disable and re-enable preserve custom namespace order", async () => { const namespaces = { side: "stored-account", main: "@main" }; const config = { ...baseConfig(), codexAccountNamespaces: namespaces }; From cbb62975b577db6fd3ffa31ab58689005a861d63 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 15:37:36 -0400 Subject: [PATCH 04/19] fix(codex): address lifecycle review follow-ups --- .../docs/ja/reference/management-api.md | 2 +- src/codex/catalog-refresh-status.ts | 44 +++++++++----- src/server/management/context.ts | 7 +-- tests/codex-auth-api.test.ts | 46 ++++++++++++-- tests/codex-catalog-refresh-status.test.ts | 60 +++++++++++++++++++ 5 files changed, 133 insertions(+), 26 deletions(-) create mode 100644 tests/codex-catalog-refresh-status.test.ts diff --git a/docs-site/src/content/docs/ja/reference/management-api.md b/docs-site/src/content/docs/ja/reference/management-api.md index 78295934b..96b026e0a 100644 --- a/docs-site/src/content/docs/ja/reference/management-api.md +++ b/docs-site/src/content/docs/ja/reference/management-api.md @@ -201,7 +201,7 @@ Authorization: Bearer |メソッドとパス |目的 |注目すべきエラー | | --- | --- | --- | -| `GET, POST, DELETE /api/codex-auth/accounts` | Codex アカウントの一覧表示/更新、必要に応じてインポート、削除。add/delete response には `catalogRefreshPending` が含まれます | 400 無効な入力。手動インポートは無効にすることができます。 +| `GET, POST, DELETE /api/codex-auth/accounts` | Codex アカウントの一覧表示/更新、必要に応じてインポート、削除。add/delete response には `catalogRefreshPending` が含まれます | 400 無効な入力。手動インポートは無効にできます。 | | `PUT /api/codex-auth/accounts/alias` |アカウント エイリアスの設定またはクリア | 400 無効なアカウント/エイリアス | | `PUT /api/codex-auth/accounts/pause` | 1 つのアカウントを一時停止または再開する | 400 無効なアカウント/状態。 404 アカウントが見つかりません | | `PUT /api/codex-auth/accounts/pause-exhausted` |クォータを使い果たしたアカウントを一時停止する |ミューテーションロックの失敗は 503 になります | diff --git a/src/codex/catalog-refresh-status.ts b/src/codex/catalog-refresh-status.ts index 539696cf7..f3d2ddcf0 100644 --- a/src/codex/catalog-refresh-status.ts +++ b/src/codex/catalog-refresh-status.ts @@ -1,18 +1,27 @@ +import { debugProviderDiagnostic } from "../lib/debug"; + +export type CodexCatalogRefreshCompletion = { + catalogExists: boolean; + catalogWritten?: boolean; + cacheSynced?: boolean; +}; + +type CodexCatalogRefreshFailureReason = "missing" | "unwritten" | "cache_unsynced"; + +class CodexCatalogRefreshIncompleteError extends Error { + constructor(readonly reason: CodexCatalogRefreshFailureReason) { + super("Codex catalog was not refreshed"); + this.name = "CodexCatalogRefreshIncompleteError"; + } +} + /** Treat an incomplete catalog rewrite or cache invalidation as a refresh failure. */ export function assertCodexCatalogRefreshComplete( - result: void | { - catalogExists: boolean; - catalogWritten?: boolean; - cacheSynced?: boolean; - }, + result: void | CodexCatalogRefreshCompletion, ): void { - if ( - result?.catalogExists === false - || result?.catalogWritten === false - || result?.cacheSynced === false - ) { - throw new Error("Codex catalog was not refreshed"); - } + if (result?.catalogExists === false) throw new CodexCatalogRefreshIncompleteError("missing"); + if (result?.catalogWritten === false) throw new CodexCatalogRefreshIncompleteError("unwritten"); + if (result?.cacheSynced === false) throw new CodexCatalogRefreshIncompleteError("cache_unsynced"); } /** @@ -26,12 +35,17 @@ export async function refreshCodexCatalogWithRetry( refresh: () => Promise, ): Promise { for (let attempt = 0; attempt < 2; attempt += 1) { + if (attempt > 0) await Bun.sleep(50); try { await refresh(); return false; - } catch { - // Retry once. Failure details may contain provider or filesystem data, so - // the terminal warning below stays generic. + } catch (error) { + // Log only an internal classification. Raw failures can contain credentials, + // account identifiers, provider URLs, or filesystem paths. + debugProviderDiagnostic("codex", "catalog-refresh-failed", { + attempt: attempt + 1, + reason: error instanceof CodexCatalogRefreshIncompleteError ? error.reason : "exception", + }); } } diff --git a/src/server/management/context.ts b/src/server/management/context.ts index 765202c75..e5e8eb9a2 100644 --- a/src/server/management/context.ts +++ b/src/server/management/context.ts @@ -5,15 +5,12 @@ import type { ManagementPrincipal } from "../management-auth"; import type { CatalogModel } from "../../codex/catalog"; import type { injectGrokConfig } from "../../grok/inject"; import type { RuntimePortState } from "../../config"; +import type { CodexCatalogRefreshCompletion } from "../../codex/catalog-refresh-status"; export interface ManagementApiDeps { toggleCodexMultiAgentV2?: (enabled: boolean) => void; toggleDefaultModeRequestUserInput?: (enabled: boolean) => void; - refreshCodexCatalog?: () => Promise; + refreshCodexCatalog?: () => Promise; /** * Persistence seam for route-level tests. Production leaves this unset and uses * `saveConfigPreservingClaudeCode`; tests that pass an in-memory fixture config diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index fb94bd238..7da244512 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -186,7 +186,7 @@ async function completeMockCodexOAuth(options: { const statusResp = await handleCodexAuthAPI(statusReq, new URL(statusReq.url), options.config); const state = await statusResp!.json() as { status: string; error?: string; catalogRefreshPending?: boolean }; if (state.status !== "pending") return { startStatus: resp!.status, state }; - await new Promise(resolve => queueMicrotask(resolve)); + await Bun.sleep(5); } throw new Error(`Timed out waiting for Codex OAuth flow ${started.flowId}`); } finally { @@ -3098,7 +3098,26 @@ describe("codex-auth API", () => { expect(isAccountNeedsReauth("pool-delete")).toBe(false); }); - test("enabled picker deletion retains its binding and refreshes after durable removal", async () => { + test.each([ + ["enabled matching binding", true, "lifecycle-delete", true], + ["disabled matching binding", false, "lifecycle-delete", false], + ["enabled orphaned binding", true, "missing-account", false], + ] as const)("delete lifecycle reports picker visibility for %s", (_case, enabled, target, expected) => { + const accountId = "lifecycle-delete"; + const config = makeConfig({ + codexAccounts: [{ id: accountId, isMain: false }], + codexAccountNamespaces: { team: target }, + codexAccountPickerEnabled: enabled, + }); + + expect(deleteCodexAccount(config, accountId)).toBe(expected); + expect(config.codexAccounts).toEqual([]); + expect(config.codexAccountNamespaces).toEqual({ team: target }); + }); + + test("enabled picker deletion retains its binding and refreshes after durable removal and re-add", async () => { + enableManualImport(); + mockCodexWarmupSuccess(); const accountId = "picker-delete"; const config = makeConfig({ codexAccounts: [{ id: accountId, email: "delete@example.test", isMain: false }], @@ -3109,11 +3128,18 @@ describe("codex-auth API", () => { accessToken: "delete-access", refreshToken: "delete-refresh", expiresAt: Date.now() + 60_000, chatgptAccountId: "delete-chatgpt-id", }); + let refreshes = 0; const refreshSpy = spyOn(codexRefresh, "refreshCodexModelCatalog").mockImplementation(async () => { + refreshes += 1; const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")) as OcxConfig; - expect(persisted.codexAccounts).toEqual([]); expect(persisted.codexAccountNamespaces).toEqual({ team: accountId }); - expect(getCodexAccountCredential(accountId)).toBeNull(); + if (refreshes === 1) { + expect(persisted.codexAccounts).toEqual([]); + expect(getCodexAccountCredential(accountId)).toBeNull(); + } else { + expect(persisted.codexAccounts?.map(account => account.id)).toEqual([accountId]); + expect(getCodexAccountCredential(accountId)).not.toBeNull(); + } return { added: 0, path: "catalog.json", catalogExists: true, catalogWritten: true, cacheSynced: true, comboOmissions: [], @@ -3125,7 +3151,17 @@ describe("codex-auth API", () => { expect(resp!.status).toBe(200); expect(await resp!.json()).toEqual({ ok: true, catalogRefreshPending: false }); expect(config.codexAccountNamespaces).toEqual({ team: accountId }); - expect(refreshSpy).toHaveBeenCalledTimes(1); + + const addReq = new Request("http://localhost/api/codex-auth/accounts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(manualImportBody({ id: accountId })), + }); + const addResp = await handleCodexAuthAPI(addReq, new URL(addReq.url), config); + expect(addResp!.status).toBe(200); + expect(await addResp!.json()).toEqual({ ok: true, catalogRefreshPending: false }); + expect(config.codexAccountNamespaces).toEqual({ team: accountId }); + expect(refreshSpy).toHaveBeenCalledTimes(2); } finally { refreshSpy.mockRestore(); } diff --git a/tests/codex-catalog-refresh-status.test.ts b/tests/codex-catalog-refresh-status.test.ts new file mode 100644 index 000000000..7605dce2c --- /dev/null +++ b/tests/codex-catalog-refresh-status.test.ts @@ -0,0 +1,60 @@ +import { afterEach, expect, spyOn, test } from "bun:test"; +import { + assertCodexCatalogRefreshComplete, + refreshCodexCatalogWithRetry, +} from "../src/codex/catalog-refresh-status"; +import { getDebugLogEntries, resetDebugLogBufferForTests } from "../src/lib/debug-log-buffer"; +import { resetDebugSettingsForTests, setDebugSettings } from "../src/lib/debug-settings"; + +afterEach(() => { + resetDebugSettingsForTests(); + resetDebugLogBufferForTests(); +}); + +test.each([ + ["missing", { catalogExists: false }], + ["unwritten", { catalogExists: true, catalogWritten: false }], + ["cache_unsynced", { catalogExists: true, catalogWritten: true, cacheSynced: false }], +] as const)("catalog refresh records only the internal %s classification", async (reason, result) => { + setDebugSettings({ debug: true }); + const errorLog = spyOn(console, "error").mockImplementation(() => {}); + const warning = spyOn(console, "warn").mockImplementation(() => {}); + try { + const pending = await refreshCodexCatalogWithRetry(async () => { + assertCodexCatalogRefreshComplete(result); + }); + + expect(pending).toBe(true); + const lines = getDebugLogEntries().map(entry => entry.line); + expect(lines).toHaveLength(2); + expect(lines[0]).toContain(`"attempt":1`); + expect(lines[1]).toContain(`"attempt":2`); + expect(lines.every(line => line.includes(`"reason":"${reason}"`))).toBe(true); + } finally { + warning.mockRestore(); + errorLog.mockRestore(); + } +}); + +test("catalog refresh diagnostics never retain raw exception details", async () => { + setDebugSettings({ debug: true }); + const errorLog = spyOn(console, "error").mockImplementation(() => {}); + const warning = spyOn(console, "warn").mockImplementation(() => {}); + const privateDetail = "https://alice:horse-battery@example.test/home/example/acct-123456"; + try { + const pending = await refreshCodexCatalogWithRetry(async () => { + throw new Error(privateDetail); + }); + + expect(pending).toBe(true); + const lines = getDebugLogEntries().map(entry => entry.line).join("\n"); + expect(lines).toContain(`"reason":"exception"`); + expect(lines).not.toContain(privateDetail); + expect(lines).not.toContain("horse-battery"); + expect(lines).not.toContain("alice"); + expect(lines).not.toContain("acct-123456"); + } finally { + warning.mockRestore(); + errorLog.mockRestore(); + } +}); From 300246db934cdc91b24bf9dfbd8f1b3eafd34865 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 16:18:24 -0400 Subject: [PATCH 05/19] fix(codex): surface pending catalog refreshes --- .../ja/reference/configuration/routing.md | 8 +- .../ko/reference/configuration/routing.md | 8 +- .../docs/reference/configuration/routing.md | 10 ++- .../ru/reference/configuration/routing.md | 9 ++- .../zh-cn/reference/configuration/routing.md | 6 +- gui/src/codex-account-mutation.ts | 11 +++ gui/src/components/AddCodexAccountModal.tsx | 3 +- gui/src/components/CodexAccountPool.tsx | 35 ++++++--- .../codex-account-pool-main-card.tsx | 5 +- .../components/use-add-codex-account-oauth.ts | 19 ++++- gui/src/hooks/useCodexAccountPool.ts | 6 +- gui/src/hooks/useJsonConfigEditor.ts | 9 ++- gui/src/hooks/useProviderAccountPools.ts | 33 ++++---- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/notice-tone.ts | 3 + gui/src/pages/Providers.tsx | 26 ++++--- gui/src/pages/providers-page-modals.tsx | 3 +- gui/src/pages/use-providers-crud.ts | 19 ++--- gui/src/pages/use-providers-fetch.ts | 5 +- gui/src/pages/use-providers-oauth.ts | 25 ++++--- gui/src/styles.css | 6 ++ gui/src/ui.tsx | 5 +- gui/tests/add-codex-account-oauth.test.tsx | 43 +++++++++-- .../codex-account-pool-behaviour.test.tsx | 28 ++++++- .../codex-account-pool-toast-tone.test.tsx | 25 +++++++ src/cli/account-auth.ts | 2 + src/cli/account-catalog-refresh.ts | 12 +++ src/cli/account-extended.ts | 16 +++- tests/cli-account.test.ts | 75 ++++++++++++++++++- tests/provider-workspace-auth.test.ts | 2 +- 35 files changed, 362 insertions(+), 101 deletions(-) create mode 100644 gui/src/codex-account-mutation.ts create mode 100644 gui/src/notice-tone.ts create mode 100644 src/cli/account-catalog-refresh.ts diff --git a/docs-site/src/content/docs/ja/reference/configuration/routing.md b/docs-site/src/content/docs/ja/reference/configuration/routing.md index 2c4c32092..b69d23cfc 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ja/reference/configuration/routing.md @@ -37,9 +37,11 @@ selector の後には bare native OpenAI-family id だけを指定できます 明示的な選択は Pool assignment strategy と通常の thread affinity を迂回します。対応する account が 存在しない、一時停止中、cooldown 中、利用不能、または再認証が必要な場合、request は別の account -へ切り替えず fail closed し、active Pool account も変更しません。適格な selector が 1 つ以上 -設定されると、Codex catalog は bare native picker row を非表示にし、selector ごとに個別の -`/` row を表示します。bare native model id は明示的に無効化されない +へ切り替えず fail closed し、active Pool account も変更しません。account-qualified picker の表示が +有効で、適格な selector が 1 つ以上ある場合、Codex picker は bare native row を非表示にし、適格な +selector ごとに個別の `/` row を表示します。`codexAccountPickerEnabled` +が `false` の場合、生成された selector row は非表示になりますが、bare native picker row はこの設定に +よって非表示にはならず、設定済みの exact selector route は引き続き利用できます。bare native model id は明示的に無効化されない 限り通常の Pool / Direct routing を維持し、raw `/v1/models` にも残ります。対応する保存済み account が存在しない selector は表示されません。selector の検証、衝突規則、privacy guidance は [プロバイダーの構成](/reference/configuration/providers/)を参照してください。 diff --git a/docs-site/src/content/docs/ko/reference/configuration/routing.md b/docs-site/src/content/docs/ko/reference/configuration/routing.md index 0ae010707..c635a1dfa 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ko/reference/configuration/routing.md @@ -36,9 +36,11 @@ id만 사용할 수 있습니다. 명시적 선택은 Pool assignment strategy와 일반 thread affinity를 우회합니다. 매핑된 account가 없거나, 일시 중지되었거나, cooldown 중이거나, 사용할 수 없거나, 재인증이 필요하면 다른 account로 전환하지 -않고 fail closed하며 active Pool account도 변경하지 않습니다. 적격 selector가 하나 이상 설정되면 -Codex catalog는 bare native picker row를 숨기고 각 selector마다 별도의 -`/` row를 표시합니다. bare native model id는 명시적으로 비활성화하지 +않고 fail closed하며 active Pool account도 변경하지 않습니다. account-qualified picker 표시가 활성화되어 +있고 적격 selector가 하나 이상 있으면 Codex picker는 bare native row를 숨기고 각 적격 selector마다 별도의 +`/` row를 표시합니다. `codexAccountPickerEnabled`가 `false`이면 생성된 +selector row는 숨겨지지만 bare native picker row는 이 설정 때문에 숨겨지지 않으며, 설정된 exact selector +route는 계속 사용할 수 있습니다. bare native model id는 명시적으로 비활성화하지 않는 한 기존 Pool / Direct routing을 유지하고 raw `/v1/models`에도 남습니다. 매핑된 저장 계정이 없는 selector는 표시되지 않습니다. selector 검증, 충돌 규칙, privacy guidance는 [공급자 설정](/reference/configuration/providers/)을 참고하십시오. diff --git a/docs-site/src/content/docs/reference/configuration/routing.md b/docs-site/src/content/docs/reference/configuration/routing.md index 85a5c8917..f579d4b52 100644 --- a/docs-site/src/content/docs/reference/configuration/routing.md +++ b/docs-site/src/content/docs/reference/configuration/routing.md @@ -40,10 +40,12 @@ ids are valid after the selector. Exact selection bypasses Pool assignment strategy and ordinary thread affinity. If the mapped account is missing, paused, cooling down, unusable, or requires reauthentication, the request fails -closed instead of switching accounts and does not change the active Pool account. When at least one -eligible selector is configured, Codex catalogs hide bare native picker rows and list a separate -`/` row for each selector. Bare native ids retain normal Pool/Direct -routing and remain in raw `/v1/models` discovery unless explicitly disabled. Selectors whose mapped +closed instead of switching accounts and does not change the active Pool account. When account-qualified +picker visibility is enabled and at least one eligible selector exists, the Codex picker hides bare native +rows and lists a separate `/` row for each eligible selector. When +`codexAccountPickerEnabled` is `false`, generated selector rows are hidden without suppressing bare native +picker rows, while configured exact selector routes continue to work. Bare native ids retain normal +Pool/Direct routing and remain in raw `/v1/models` discovery unless explicitly disabled. Selectors whose mapped stored account is missing are not advertised. Selector validation, collision rules, and privacy guidance are documented in [Provider Configuration](/reference/configuration/providers/). diff --git a/docs-site/src/content/docs/ru/reference/configuration/routing.md b/docs-site/src/content/docs/ru/reference/configuration/routing.md index e7deed4ae..5352d8202 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/routing.md +++ b/docs-site/src/content/docs/ru/reference/configuration/routing.md @@ -42,9 +42,12 @@ opencodex разрешает запрошенную модель в следую Точный выбор обходит стратегию назначения Pool и обычную thread affinity. Если сопоставленный аккаунт отсутствует, приостановлен, находится в cooldown, непригоден или требует повторной аутентификации, запрос -завершается ошибкой без переключения на другой аккаунт и без изменения active Pool account. Если -настроен хотя бы один допустимый селектор, каталоги Codex скрывают bare native-строки picker и добавляют -отдельную строку `/` для каждого селектора. Bare native-id сохраняют +завершается ошибкой без переключения на другой аккаунт и без изменения active Pool account. Когда +видимость account-qualified picker включена и существует хотя бы один допустимый селектор, picker Codex +скрывает bare native-строки и добавляет отдельную строку `/` для каждого +допустимого селектора. Если `codexAccountPickerEnabled` имеет значение `false`, созданные selector-row +скрываются, но bare native-строки picker из-за этой настройки не скрываются, а настроенные exact selector +route продолжают работать. Bare native-id сохраняют обычную маршрутизацию Pool / Direct и остаются в raw `/v1/models`, если не отключены явно. Селекторы, чей сохранённый аккаунт отсутствует, не рекламируются. Проверка selector, правила коллизий и рекомендации по privacy описаны в разделе [Конфигурация провайдеров](/reference/configuration/providers/). diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md index 91de85c76..c02e7b3a5 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/routing.md @@ -39,8 +39,10 @@ upstream 发送裸 `gpt-5.6-sol` model id。selector 后只能使用裸原生 Op 精确选择会绕过 Pool 分配策略和普通 thread affinity。若映射账户不存在、已暂停、处于 cooldown、 不可用或需要重新认证,请求会 fail closed,不会切换到其他账户,也不会改变 active Pool account。 -配置至少一个合格 selector 后,Codex catalog 会隐藏 bare native picker row,并为每个 selector 显示 -独立的 `/` row。除非显式禁用,bare native model id 仍保持正常的 Pool / +启用 account-qualified picker 可见性且至少存在一个合格 selector 时,Codex picker 会隐藏 bare native row, +并为每个合格 selector 显示独立的 `/` row。当 +`codexAccountPickerEnabled` 为 `false` 时,生成的 selector row 会隐藏,但 bare native picker row 不会因此被 +隐藏,已配置的 exact selector route 仍可使用。除非显式禁用,bare native model id 仍保持正常的 Pool / Direct routing,并继续出现在 raw `/v1/models` 中。映射到缺失已保存账户的 selector 不会被展示。 selector 校验、冲突规则和隐私说明见[提供方配置](/reference/configuration/providers/)。 diff --git a/gui/src/codex-account-mutation.ts b/gui/src/codex-account-mutation.ts new file mode 100644 index 000000000..fb496a7cb --- /dev/null +++ b/gui/src/codex-account-mutation.ts @@ -0,0 +1,11 @@ +export interface CodexAccountMutationCompletion { + catalogRefreshPending: boolean; +} + +/** Project the public completion flag without forwarding account or error details. */ +export function codexAccountMutationCompletion(value: unknown): CodexAccountMutationCompletion { + const payload = value && typeof value === "object" + ? value as Record + : {}; + return { catalogRefreshPending: payload.catalogRefreshPending === true }; +} diff --git a/gui/src/components/AddCodexAccountModal.tsx b/gui/src/components/AddCodexAccountModal.tsx index 0217dd3af..1c86d910d 100644 --- a/gui/src/components/AddCodexAccountModal.tsx +++ b/gui/src/components/AddCodexAccountModal.tsx @@ -7,13 +7,14 @@ import { import { AddCodexAccountPickStep } from "./add-codex-account-pick-step"; import { AddCodexAccountWaitingStep } from "./add-codex-account-waiting-step"; import { useAddCodexAccountOAuth } from "./use-add-codex-account-oauth"; +import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; export default function AddCodexAccountModal({ apiBase, onClose, onAdded, reauthAccountId, }: { apiBase: string; onClose: () => void; - onAdded: () => void; + onAdded: (completion: CodexAccountMutationCompletion) => void; reauthAccountId?: string; }) { const t = useT(); diff --git a/gui/src/components/CodexAccountPool.tsx b/gui/src/components/CodexAccountPool.tsx index f6b038676..65bd0e266 100644 --- a/gui/src/components/CodexAccountPool.tsx +++ b/gui/src/components/CodexAccountPool.tsx @@ -19,6 +19,8 @@ import type { CodexAccountEntry } from "./codex-account-pool-types"; import { accountNeedsReauth } from "../oauth-health-display"; import { useCopyFeedback } from "./use-copy-feedback"; import { DEFAULT_ACCOUNT_POOL_STRATEGY } from "../account-pool-strategy"; +import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; +import type { NoticeTone } from "../notice-tone"; // Single definition lives with the controller that owns this data (WP3). export type { CodexAccountEntry } from "../hooks/useCodexAccountPool"; @@ -64,7 +66,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const [showAdd, setShowAdd] = useState(false); const [reauthId, setReauthId] = useState(null); const [actionFeedback, setActionFeedback] = useState(null); - const [actionFeedbackTone, setActionFeedbackTone] = useState<"ok" | "err" | null>(null); + const [actionFeedbackTone, setActionFeedbackTone] = useState(null); const feedbackTimerRef = useRef | null>(null); const [refreshingQuota, setRefreshingQuota] = useState(false); const [resetPopup, setResetPopup] = useState(null); @@ -74,10 +76,10 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const [creditDetailsLoading, setCreditDetailsLoading] = useState(false); const doctorCopy = useCopyFeedback(); - const showActionFeedback = useCallback((text: string, error = false) => { + const showActionFeedback = useCallback((text: string, tone: NoticeTone = "ok") => { if (feedbackTimerRef.current) clearTimeout(feedbackTimerRef.current); setActionFeedback(text); - setActionFeedbackTone(error ? "err" : "ok"); + setActionFeedbackTone(tone); feedbackTimerRef.current = setTimeout(() => { setActionFeedback(null); setActionFeedbackTone(null); @@ -139,9 +141,14 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban setReauthId(null); }, []); - const handleAccountAdded = useCallback(() => { + const handleAccountAdded = useCallback((completion: CodexAccountMutationCompletion) => { void controller.syncAfterAccountAdded(); - showActionFeedback(t("codexAuth.accountAdded")); + showActionFeedback( + completion.catalogRefreshPending + ? t("codexAuth.catalogRefreshPending", { cmd: "ocx sync" }) + : t("codexAuth.accountAdded"), + completion.catalogRefreshPending ? "warn" : "ok", + ); closeAddModal(); }, [closeAddModal, controller, showActionFeedback, t]); @@ -149,7 +156,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const result = await controller.switchAccount(id); if (!result.ok) { if (result.reason === "busy") return; - showActionFeedback(t("codexAuth.switchFailed"), true); + showActionFeedback(t("codexAuth.switchFailed"), "err"); return; } setConfirm(null); @@ -166,7 +173,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban const entered = window.prompt(t("prov.aliasPrompt"), account.alias ?? ""); if (entered === null) return; const result = await controller.saveAlias(account.id, entered); - showActionFeedback(t(result.ok ? "prov.aliasSaved" : "prov.aliasSaveFailed"), !result.ok); + showActionFeedback(t(result.ok ? "prov.aliasSaved" : "prov.aliasSaveFailed"), result.ok ? "ok" : "err"); }; const togglePaused = async (account: CodexAccountEntry) => { @@ -178,7 +185,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban ? paused ? "codexAuth.pauseSucceeded" : "codexAuth.resumeSucceeded" : paused ? "codexAuth.pauseFailed" : "codexAuth.resumeFailed", { email: account.alias ?? account.email, - }), !result.ok); + }), result.ok ? "ok" : "err"); }; const remove = async (id: string) => { @@ -186,7 +193,11 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban if (!window.confirm(t("codexAuth.removeConfirm", { id: label }))) return; const result = await controller.removeAccount(id); if (!result.ok) { - showActionFeedback(t("codexAuth.removeFailed"), true); + showActionFeedback(t("codexAuth.removeFailed"), "err"); + return; + } + if (result.catalogRefreshPending) { + showActionFeedback(t("codexAuth.catalogRefreshPending", { cmd: "ocx sync" }), "warn"); } }; @@ -194,7 +205,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban setRefreshingQuota(true); try { const ok = await load(true); - showActionFeedback(t(ok ? "codexAuth.quotaRefreshed" : "codexAuth.quotaRefreshFailed"), !ok); + showActionFeedback(t(ok ? "codexAuth.quotaRefreshed" : "codexAuth.quotaRefreshFailed"), ok ? "ok" : "err"); } finally { setRefreshingQuota(false); } @@ -207,7 +218,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban ? result.pausedCount > 0 ? t("codexAuth.pauseExhaustedSucceeded", { count: String(result.pausedCount) }) : t("codexAuth.pauseExhaustedNone") - : t("codexAuth.pauseExhaustedFailed"), !result.ok); + : t("codexAuth.pauseExhaustedFailed"), result.ok ? "ok" : "err"); }; const openResetPopup = async (account: CodexAccountEntry) => { @@ -237,7 +248,7 @@ export default function CodexAccountPool({ apiBase, accountModeState = null, ban setResetConfirm(false); } if (result.toast) { - showActionFeedback(result.toast, !result.ok); + showActionFeedback(result.toast, result.ok ? "ok" : "err"); } } finally { setRedeeming(false); diff --git a/gui/src/components/codex-account-pool-main-card.tsx b/gui/src/components/codex-account-pool-main-card.tsx index c08423b95..d292dcbc7 100644 --- a/gui/src/components/codex-account-pool-main-card.tsx +++ b/gui/src/components/codex-account-pool-main-card.tsx @@ -5,6 +5,7 @@ import { CodexPauseToggleLabel, CodexTicketBadge } from "./codex-account-pool-he import type { CodexAccountEntry } from "./codex-account-pool-types"; import type { CodexAccountModeState } from "../codex-multi-state"; import type { TFn } from "../i18n/shared"; +import type { NoticeTone } from "../notice-tone"; import { doctorCopyButtonLabel, formatOAuthHealthLabel, @@ -154,7 +155,7 @@ export function CodexAccountPoolPageHead({ pausingExhausted: boolean; pauseBusy?: boolean; actionFeedback?: string | null; - actionFeedbackTone?: "ok" | "err" | null; + actionFeedbackTone?: NoticeTone | null; onRefresh: () => void; onPauseExhausted: () => void; }) { @@ -166,7 +167,7 @@ export function CodexAccountPoolPageHead({ {!embedded &&

{t("nav.codexAuth")}

}
diff --git a/gui/src/components/use-add-codex-account-oauth.ts b/gui/src/components/use-add-codex-account-oauth.ts index c549a2903..a5b959e85 100644 --- a/gui/src/components/use-add-codex-account-oauth.ts +++ b/gui/src/components/use-add-codex-account-oauth.ts @@ -7,6 +7,10 @@ import type { } from "./add-codex-account-reducer"; import type { TFn } from "../i18n/shared"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; +import { + codexAccountMutationCompletion, + type CodexAccountMutationCompletion, +} from "../codex-account-mutation"; export function useAddCodexAccountOAuth({ apiBase, @@ -37,7 +41,7 @@ export function useAddCodexAccountOAuth({ const manualCodeStateRef = useRef(ui.manualCodeState); const loginAbortRef = useRef(null); const startedReauthRef = useRef(null); - const onAddedRef = useRef<() => void>(() => {}); + const onAddedRef = useRef<(completion: CodexAccountMutationCompletion) => void>(() => {}); const onCloseRef = useRef<() => void>(() => {}); const manualCodeBusy = ui.manualCodeState === "submitting"; @@ -104,7 +108,10 @@ export function useAddCodexAccountOAuth({ }; }, [apiBase, clearManualCode, dispatch, stopPolling]); - const bindCallbacks = useCallback((onAdded: () => void, onClose: () => void) => { + const bindCallbacks = useCallback(( + onAdded: (completion: CodexAccountMutationCompletion) => void, + onClose: () => void, + ) => { onAddedRef.current = onAdded; onCloseRef.current = onClose; }, []); @@ -177,7 +184,11 @@ export function useAddCodexAccountOAuth({ ]); try { const stRes = await fetch(statusUrl, { signal: tickSignal }); - const st = await readJsonIfOk<{ status: string; error?: string }>(stRes); + const st = await readJsonIfOk<{ + status: string; + error?: string; + catalogRefreshPending?: boolean; + }>(stRes); if (!aliveRef.current || pollSession.signal.aborted) return; if (!st) { pollErrorStreakRef.current += 1; @@ -198,7 +209,7 @@ export function useAddCodexAccountOAuth({ flowRef.current = null; dispatch({ type: "set-flow-id", flowId: null }); if (!aliveRef.current) return; - onAddedRef.current(); + onAddedRef.current(codexAccountMutationCompletion(st)); onCloseRef.current(); } else if (st.status === "error" || st.status === "expired") { stopPolling(); diff --git a/gui/src/hooks/useCodexAccountPool.ts b/gui/src/hooks/useCodexAccountPool.ts index 9f610f9d6..3f96ac426 100644 --- a/gui/src/hooks/useCodexAccountPool.ts +++ b/gui/src/hooks/useCodexAccountPool.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { extractAutoSwitchThresholdPayload } from "../codex-auto-switch"; import type { AccountQuota } from "../codex-quota-utils"; import { accountNeedsReauth } from "../oauth-health-display"; +import { codexAccountMutationCompletion } from "../codex-account-mutation"; /** * Codex account pool DATA layer (WP3 / 030_account_state_lift.md). @@ -75,7 +76,7 @@ export interface CodexAccountPoolController { setAccountPaused(id: string, paused: boolean): Promise; pauseExhaustedAccounts(): Promise>; saveAlias(id: string, alias: string): Promise; - removeAccount(id: string): Promise; + removeAccount(id: string): Promise>; syncAfterAccountAdded(): Promise; pauseRefresh(): PauseToken; @@ -410,8 +411,9 @@ export function useCodexAccountPool(apiBase: string, enabled = true): CodexAccou { method: "DELETE" }, ); if (!response.ok) return { ok: false, reason: "request" } as const; + const completion = codexAccountMutationCompletion(await response.json().catch(() => ({}))); await load(); - return { ok: true } as const; + return { ok: true, ...completion } as const; } catch { return { ok: false, reason: "request" } as const; } diff --git a/gui/src/hooks/useJsonConfigEditor.ts b/gui/src/hooks/useJsonConfigEditor.ts index b2f53a3b4..e97c9ffd8 100644 --- a/gui/src/hooks/useJsonConfigEditor.ts +++ b/gui/src/hooks/useJsonConfigEditor.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import type { Notify } from "../notice-tone"; export interface Config { port: number; @@ -9,7 +10,7 @@ export interface Config { export function useJsonConfigEditor(deps: { apiBase: string; config: Config | null; - notify: (msg: string, ok?: boolean) => void; + notify: Notify; fetchConfig: () => Promise; fetchProviderQuotas: (refresh?: boolean) => Promise; onSaved: () => void; @@ -39,10 +40,10 @@ export function useJsonConfigEditor(deps: { }); if (!res.ok) { const data = await res.json().catch(() => ({})) as { error?: string }; - notify(data.error || t("prov.saveFailed"), false); + notify(data.error || t("prov.saveFailed"), "err"); return false; } - notify(t("prov.saved"), true); + notify(t("prov.saved"), "ok"); setEditing(false); setJsonEditorOpen(false); jsonEditorOpenRef.current = false; @@ -53,7 +54,7 @@ export function useJsonConfigEditor(deps: { onSaved(); return true; } catch { - notify(t("prov.invalidJson"), false); + notify(t("prov.invalidJson"), "err"); return false; } finally { setJsonSaving(false); diff --git a/gui/src/hooks/useProviderAccountPools.ts b/gui/src/hooks/useProviderAccountPools.ts index bfddc0611..9decb2f67 100644 --- a/gui/src/hooks/useProviderAccountPools.ts +++ b/gui/src/hooks/useProviderAccountPools.ts @@ -3,6 +3,7 @@ import type { AccountLoadState } from "../components/provider-workspace/types"; import { accountNeedsReauth } from "../oauth-health-display"; import type { AccountQuota } from "../codex-quota-utils"; import { oauthAccountDisplayLabel } from "../provider-workspace/auth"; +import type { Notify } from "../notice-tone"; export interface Config { port: number; @@ -49,7 +50,7 @@ export function useProviderAccountPools(deps: { config: Config | null; oauthStatus: Record; aliveRef: MutableRefObject; - notify: (msg: string, ok?: boolean) => void; + notify: Notify; fetchConfig: () => Promise; fetchOauth: () => Promise; fetchProviderQuotas: (refresh?: boolean) => Promise; @@ -138,13 +139,13 @@ export function useProviderAccountPools(deps: { const label = oauthAccountDisplayLabel(accountSets[provider]?.accounts ?? [account], account, t); try { const res = await fetch(`${apiBase}/api/oauth/accounts/active`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider, accountId: account.id }) }); - if (!res.ok) { notify(t("prov.accountSwitchFail"), false); return; } + if (!res.ok) { notify(t("prov.accountSwitchFail"), "err"); return; } const refreshed = await fetchAccountSets([provider]); await Promise.all([fetchOauth(), fetchProviderQuotas(true)]); - if (!refreshed) { notify(t("pws.accountsLoadFailed"), false); return; } - notify(t("prov.accountSwitched", { email: label }), true); + if (!refreshed) { notify(t("pws.accountsLoadFailed"), "err"); return; } + notify(t("prov.accountSwitched", { email: label }), "ok"); } catch { - notify(t("prov.accountSwitchFail"), false); + notify(t("prov.accountSwitchFail"), "err"); } finally { if (switchingAccountRef.current?.provider === target.provider && switchingAccountRef.current.accountId === target.accountId) { switchingAccountRef.current = null; @@ -157,12 +158,12 @@ export function useProviderAccountPools(deps: { if (entry.active) return; const res = await fetch(`${apiBase}/api/providers/keys/active`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: provider, id: entry.id }) }); if (res.ok) { - notify(t("prov.keySwitched", { key: entry.label ?? entry.masked }), true); + notify(t("prov.keySwitched", { key: entry.label ?? entry.masked }), "ok"); void fetchKeyPools(Object.keys(keyPools)); void fetchProviderQuotas(true); } else { const data = await res.json().catch(() => ({})); - notify(data.error || t("prov.keySwitchFail"), false); + notify(data.error || t("prov.keySwitchFail"), "err"); } }; @@ -170,7 +171,7 @@ export function useProviderAccountPools(deps: { if (!window.confirm(t("prov.keyRemoveConfirm", { key: entry.label ?? entry.masked }))) return; const res = await fetch(`${apiBase}/api/providers/keys?name=${encodeURIComponent(provider)}&id=${encodeURIComponent(entry.id)}`, { method: "DELETE" }); if (res.ok) { - notify(t("prov.keyRemoved", { key: entry.label ?? entry.masked }), true); + notify(t("prov.keyRemoved", { key: entry.label ?? entry.masked }), "ok"); void fetchKeyPools(Object.keys(keyPools)); void fetchConfig(); void fetchProviderQuotas(true); @@ -184,10 +185,10 @@ export function useProviderAccountPools(deps: { const res = await fetch(`${apiBase}/api/providers/keys`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: provider, key }) }); if (!res.ok) { const data = await res.json().catch(() => ({})) as { error?: string }; - notify(data.error || t("prov.keyAddFail"), false); + notify(data.error || t("prov.keyAddFail"), "err"); return false; } - notify(t("prov.keyAdded", { name: provider }), true); + notify(t("prov.keyAdded", { name: provider }), "ok"); setAddingKeyFor(null); await Promise.all([ fetchKeyPools(Object.keys(keyPools).includes(provider) ? Object.keys(keyPools) : [...Object.keys(keyPools), provider]), @@ -195,7 +196,7 @@ export function useProviderAccountPools(deps: { ]); return true; } catch { - notify(t("prov.keyAddFail"), false); + notify(t("prov.keyAddFail"), "err"); return false; } }; @@ -215,12 +216,12 @@ export function useProviderAccountPools(deps: { }); if (!response.ok) { const data = await response.json().catch(() => ({})) as { error?: string }; - notify(data.error || t("prov.aliasSaveFailed"), false); + notify(data.error || t("prov.aliasSaveFailed"), "err"); return; } if (type === "oauth") await fetchAccountSets([provider]); else await fetchKeyPools(Object.keys(keyPools).includes(provider) ? Object.keys(keyPools) : [...Object.keys(keyPools), provider]); - notify(t("prov.aliasSaved"), true); + notify(t("prov.aliasSaved"), "ok"); }; const removeAccount = async (provider: string, account: OAuthAccount) => { @@ -228,12 +229,12 @@ export function useProviderAccountPools(deps: { if (!window.confirm(t("prov.accountRemoveConfirm", { email: label }))) return; try { const res = await fetch(`${apiBase}/api/oauth/accounts?provider=${encodeURIComponent(provider)}&id=${encodeURIComponent(account.id)}`, { method: "DELETE" }); - if (!res.ok) { notify(t("prov.accountRemoveFail", { email: label }), false); return; } - notify(t("prov.accountRemoved", { email: label }), true); + if (!res.ok) { notify(t("prov.accountRemoveFail", { email: label }), "err"); return; } + notify(t("prov.accountRemoved", { email: label }), "ok"); await fetchAccountSets([provider]); await Promise.all([fetchOauth(), fetchProviderQuotas(true)]); } catch { - notify(t("prov.accountRemoveFail", { email: label }), false); + notify(t("prov.accountRemoveFail", { email: label }), "err"); } }; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 639f00c7a..208716d11 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -909,6 +909,7 @@ export const de: Record = { "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 Aktualisierung des Codex-Modellkatalogs steht noch aus. Führe {cmd} 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 cbfd40892..2ccb78886 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1368,6 +1368,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 Codex model catalog refresh is pending. Run {cmd} 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 23df35de4..318228313 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1314,6 +1314,7 @@ export const ja: Record = { "codexAuth.importMissingTokens": "JSON に access_token または refresh_token がありません", "codexAuth.importMissingId": "アカウント ID は必須です", "codexAuth.accountAdded": "アカウントをプールに追加しました", + "codexAuth.catalogRefreshPending": "アカウントの変更は保存されましたが、Codex モデルカタログの更新は保留中です。{cmd} を実行して再試行してください。", "codexAuth.addPickDesc": "別の ChatGPT アカウントでログインしてプールに追加します。", "codexAuth.oauthLogin": "OAuth ログイン", "codexAuth.oauthDesc": "ブラウザで ChatGPT ログインを開きます", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index b08030411..5f0292ea3 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -933,6 +933,7 @@ export const ko: Record = { "codexAuth.importMissingTokens": "JSON에 access_token 또는 refresh_token이 없습니다", "codexAuth.importMissingId": "계정 ID를 입력하세요", "codexAuth.accountAdded": "풀에 계정이 추가되었습니다", + "codexAuth.catalogRefreshPending": "계정 변경 사항은 저장되었지만 Codex 모델 카탈로그 새로 고침이 보류 중입니다. {cmd}를 실행하여 다시 시도하세요.", "codexAuth.addPickDesc": "다른 ChatGPT 계정으로 로그인하여 풀에 추가하세요.", "codexAuth.oauthLogin": "OAuth 로그인", "codexAuth.oauthDesc": "브라우저에서 ChatGPT 로그인 열기", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 98b7c6243..95c690fe3 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1356,6 +1356,7 @@ export const ru: Record = { "codexAuth.importMissingTokens": "В JSON отсутствует access_token или refresh_token", "codexAuth.importMissingId": "Укажите ID аккаунта", "codexAuth.accountAdded": "Аккаунт добавлен в пул", + "codexAuth.catalogRefreshPending": "Изменение аккаунта сохранено, но обновление каталога моделей Codex не завершилось. Выполните {cmd}, чтобы повторить попытку.", "codexAuth.addPickDesc": "Войдите в другой аккаунт ChatGPT, чтобы добавить его в пул.", "codexAuth.oauthLogin": "Вход через OAuth", "codexAuth.oauthDesc": "Открывает вход ChatGPT в браузере", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index c77a6fb6d..257a4fbb3 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -926,6 +926,7 @@ export const zh: Record = { "codexAuth.importMissingTokens": "JSON 中缺少 access_token 或 refresh_token", "codexAuth.importMissingId": "请输入账号 ID", "codexAuth.accountAdded": "账号已添加到池中", + "codexAuth.catalogRefreshPending": "账号更改已保存,但 Codex 模型目录刷新仍待处理。运行 {cmd} 重试。", "codexAuth.addPickDesc": "使用另一个 ChatGPT 账号登录以添加到池中。", "codexAuth.oauthLogin": "OAuth 登录", "codexAuth.oauthDesc": "在浏览器中打开 ChatGPT 登录", diff --git a/gui/src/notice-tone.ts b/gui/src/notice-tone.ts new file mode 100644 index 000000000..6e872ee9f --- /dev/null +++ b/gui/src/notice-tone.ts @@ -0,0 +1,3 @@ +export type NoticeTone = "ok" | "warn" | "err"; + +export type Notify = (message: string, tone?: NoticeTone) => void; diff --git a/gui/src/pages/Providers.tsx b/gui/src/pages/Providers.tsx index 736200b3f..6edc4600a 100644 --- a/gui/src/pages/Providers.tsx +++ b/gui/src/pages/Providers.tsx @@ -17,6 +17,7 @@ import type { ProvidersConfig } from "./providers-shared"; import { useProvidersOAuth } from "./use-providers-oauth"; import { useProvidersCrud } from "./use-providers-crud"; import { useProvidersFetch } from "./use-providers-fetch"; +import type { NoticeTone } from "../notice-tone"; import { ProvidersPageModals } from "./providers-page-modals"; import { buildAccountLoginStatus, buildAddModalAccountRows } from "./providers-page-utils"; @@ -28,7 +29,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { ); const [adding, setAdding] = useState(false); const [status, setStatus] = useState(""); - const [statusOk, setStatusOk] = useState(false); + const [statusTone, setStatusTone] = useState("err"); const [oauthProviders, setOauthProviders] = useState([]); const [oauthStatus, setOauthStatus] = useState>({}); const [busy, setBusy] = useState(null); @@ -46,9 +47,9 @@ export default function Providers({ apiBase }: { apiBase: string }) { const bootstrapKeyRef = useRef(null); const removeBusyRef = useRef(false); - const notify = useCallback((msg: string, ok: boolean = true) => { + const notify = useCallback((msg: string, tone: NoticeTone = "ok") => { setStatus(msg); - setStatusOk(ok); + setStatusTone(tone); }, []); useEffect(() => { aliveRef.current = true; return () => { aliveRef.current = false; }; }, []); @@ -225,7 +226,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { const configured = config.providers.openai; const state = openAiAccountProviderState(configured); if (state === "invalid") { - notify(t("codexAuth.openaiMissing"), false); + notify(t("codexAuth.openaiMissing"), "err"); return; } if (state === "absent" || state === "disabled") { @@ -235,9 +236,9 @@ export default function Providers({ apiBase }: { apiBase: string }) { await fetchConfig(); } catch (error) { if (error instanceof OpenAiEnableError) { - notify(t(error.i18nKey), false); + notify(t(error.i18nKey), "err"); } else { - notify(error instanceof Error ? error.message : t("prov.saveFailed"), false); + notify(error instanceof Error ? error.message : t("prov.saveFailed"), "err"); } return; } finally { @@ -265,7 +266,7 @@ export default function Providers({ apiBase }: { apiBase: string }) {
- {status && {status}} + {status && {status}} } @@ -361,7 +362,7 @@ export default function Providers({ apiBase }: { apiBase: string }) { onAdded={(name) => { setAdding(false); setAddIntent(null); - notify(t("prov.added", { name, cmd: "ocx sync" }), true); + notify(t("prov.added", { name, cmd: "ocx sync" }), "ok"); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); @@ -372,9 +373,14 @@ export default function Providers({ apiBase }: { apiBase: string }) { onAccountLogout={(provider) => { void logoutOAuth(provider); }} onOpenAdd={fetchOauth} onCloseCodexLogin={() => setCodexLoginOpen(false)} - onCodexAdded={() => { + onCodexAdded={(completion) => { setCodexLoginOpen(false); - notify(t("prov.loginOk", { provider: formatProviderDisplayName("openai", t), cmd: "ocx sync" }), true); + notify( + completion.catalogRefreshPending + ? t("codexAuth.catalogRefreshPending", { cmd: "ocx sync" }) + : t("prov.loginOk", { provider: formatProviderDisplayName("openai", t), cmd: "ocx sync" }), + completion.catalogRefreshPending ? "warn" : "ok", + ); void fetchConfig(); void fetchOauth(); void fetchProviderQuotas(true); diff --git a/gui/src/pages/providers-page-modals.tsx b/gui/src/pages/providers-page-modals.tsx index 58ab9fa2c..88bff2fa0 100644 --- a/gui/src/pages/providers-page-modals.tsx +++ b/gui/src/pages/providers-page-modals.tsx @@ -6,6 +6,7 @@ import type { AddProviderIntent } from "../components/provider-workspace/Provide import type { AccountLoginRow, AccountLoginStatus } from "../components/provider-catalog/ProviderCatalog"; import type { ProvidersConfig } from "./providers-shared"; import { oauthLabel } from "./providers-shared"; +import type { CodexAccountMutationCompletion } from "../codex-account-mutation"; export function ProvidersPageModals({ apiBase, @@ -57,7 +58,7 @@ export function ProvidersPageModals({ onAccountLogout: (provider: string) => void; onOpenAdd: () => void; onCloseCodexLogin: () => void; - onCodexAdded: () => void; + onCodexAdded: (completion: CodexAccountMutationCompletion) => void; onCancelRemove: () => void; onConfirmRemove: () => void; onCancelJsonLeave?: () => void; diff --git a/gui/src/pages/use-providers-crud.ts b/gui/src/pages/use-providers-crud.ts index 20794ad58..bcd495bb0 100644 --- a/gui/src/pages/use-providers-crud.ts +++ b/gui/src/pages/use-providers-crud.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import type { TFn } from "../i18n/shared"; import type { ProviderUpdatePatch } from "../components/provider-workspace/types"; import { apiErrorMessage } from "../api-error"; +import type { Notify } from "../notice-tone"; type ProviderError = { code?: unknown; combos?: unknown; error?: unknown }; @@ -37,7 +38,7 @@ export function useProvidersCrud({ workspaceSelected: string | null; setWorkspaceSelected: (name: string | null) => void; setRemoveConfirmName: (name: string | null) => void; - notify: (msg: string, ok: boolean) => void; + notify: Notify; fetchConfig: () => Promise; fetchOauth: () => Promise; fetchProviderQuotas: (refresh?: boolean) => Promise; @@ -61,17 +62,17 @@ export function useProvidersCrud({ const defaultProvider = typeof data.defaultProvider === "string" ? data.defaultProvider : null; notify(defaultProvider ? t("prov.removedDefault", { name, defaultProvider }) - : t("prov.removed", { name }), true); + : t("prov.removed", { name }), "ok"); if (workspaceSelected === name) setWorkspaceSelected(null); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); } else { const data = await res.json().catch(() => ({})) as ProviderError; - notify(providerErrorMessage(data, t, fallback), false); + notify(providerErrorMessage(data, t, fallback), "err"); } } catch { - notify(fallback, false); + notify(fallback, "err"); } finally { removeBusyRef.current = false; } @@ -84,10 +85,10 @@ export function useProvidersCrud({ body: JSON.stringify({ disabled }), }); if (!res.ok) { - notify(await apiErrorMessage(res, disabled ? t("prov.disableFail", { name }) : t("prov.enableFail", { name })), false); + notify(await apiErrorMessage(res, disabled ? t("prov.disableFail", { name }) : t("prov.enableFail", { name })), "err"); return; } - notify(disabled ? t("prov.disabled", { name }) : t("prov.enabled", { name }), true); + notify(disabled ? t("prov.disabled", { name }) : t("prov.enabled", { name }), "ok"); fetchConfig(); fetchOauth(); fetchProviderQuotas(true); @@ -128,14 +129,14 @@ export function useProvidersCrud({ }); if (!res.ok) { const data = await res.json().catch(() => ({})) as ProviderError; - notify(providerErrorMessage(data, t, t("prov.setDefaultFail", { name })), false); + notify(providerErrorMessage(data, t, t("prov.setDefaultFail", { name })), "err"); return false; } - notify(t("prov.setDefaultSuccess", { name }), true); + notify(t("prov.setDefaultSuccess", { name }), "ok"); await fetchConfig(); return true; } catch { - notify(t("prov.setDefaultFail", { name }), false); + notify(t("prov.setDefaultFail", { name }), "err"); return false; } }, [apiBase, fetchConfig, notify, t]); diff --git a/gui/src/pages/use-providers-fetch.ts b/gui/src/pages/use-providers-fetch.ts index b310731d2..8e32f776e 100644 --- a/gui/src/pages/use-providers-fetch.ts +++ b/gui/src/pages/use-providers-fetch.ts @@ -3,6 +3,7 @@ import type { TFn } from "../i18n/shared"; import { readJsonIfOk, readJsonOrThrow } from "../fetch-json"; import { writeSessionListCache } from "../session-list-cache"; import type { OAuthStatus, ProvidersConfig } from "./providers-shared"; +import type { Notify } from "../notice-tone"; export function useProvidersFetch({ apiBase, @@ -19,7 +20,7 @@ export function useProvidersFetch({ setConfig: React.Dispatch>; setOauthProviders: React.Dispatch>; setOauthStatus: React.Dispatch>>; - notify: (msg: string, ok: boolean) => void; + notify: Notify; /** Bump the shell's quota revision; `force` adds `?refresh=1` to its next read. */ invalidateProviderQuotas: (force?: boolean) => void; /** Session seed key for instant Providers shell paint (no secrets — hasApiKey flags only). */ @@ -32,7 +33,7 @@ export function useProvidersFetch({ setConfig(data ?? null); if (configCacheKey && data) writeSessionListCache(configCacheKey, data); } catch { - notify(t("prov.loadConfigFail"), false); + notify(t("prov.loadConfigFail"), "err"); } }, [apiBase, configCacheKey, notify, setConfig, t]); diff --git a/gui/src/pages/use-providers-oauth.ts b/gui/src/pages/use-providers-oauth.ts index 07f26decf..221645165 100644 --- a/gui/src/pages/use-providers-oauth.ts +++ b/gui/src/pages/use-providers-oauth.ts @@ -3,6 +3,7 @@ import type { TFn } from "../i18n/shared"; import { readJsonIfOk } from "../fetch-json"; import type { OAuthAccount, OAuthStatus } from "./providers-shared"; import { oauthLabel } from "./providers-shared"; +import type { Notify } from "../notice-tone"; export function useProvidersOAuth({ apiBase, @@ -28,7 +29,7 @@ export function useProvidersOAuth({ setStatus: React.Dispatch>; setLoginInfo: React.Dispatch>; setOauthStatus: React.Dispatch>>; - notify: (msg: string, ok: boolean) => void; + notify: Notify; fetchConfig: () => Promise; fetchOauth: () => Promise; fetchAccountSets: (providers: string[]) => Promise; @@ -53,7 +54,7 @@ export function useProvidersOAuth({ setBusy(current => current === provider ? null : current); setLoginInfo(current => current?.provider === provider ? null : current); } - notify(t("prov.loginCancelled", { provider: oauthLabel(provider) }), false); + notify(t("prov.loginCancelled", { provider: oauthLabel(provider) }), "err"); }, [aliveRef, apiBase, notify, setBusy, setLoginInfo, t]); const loginOAuth = async (provider: string, addAccount = false, accountId?: string) => { @@ -77,7 +78,7 @@ export function useProvidersOAuth({ if (oauthLoginGenerationRef.current!.get(provider) !== generation || !aliveRef.current) return; if (!res.ok) { const data = await res.json().catch(() => ({})) as { error?: string }; - notify(data.error || t("prov.loginFailStart", { provider: oauthLabel(provider) }), false); + notify(data.error || t("prov.loginFailStart", { provider: oauthLabel(provider) }), "err"); return; } const data = await res.json() as { url?: string; instructions?: string; deviceCode?: string }; @@ -101,7 +102,7 @@ export function useProvidersOAuth({ cancelled ? t("prov.loginCancelled", { provider: oauthLabel(provider) }) : t("prov.loginError", { provider: oauthLabel(provider), error: s.error }), - false, + "err", ); setLoginInfo(null); finished = true; @@ -116,18 +117,18 @@ export function useProvidersOAuth({ ? s.accounts?.find(a => a.id === reauthTargetId) : s.accounts?.find(a => a.active) ?? s.accounts?.find(a => a.id === s.activeAccountId); if (reauthTargetId && !target) { - notify(t("prov.loginError", { provider: oauthLabel(provider), error: t("prov.reauthAccountMissing") }), false); + notify(t("prov.loginError", { provider: oauthLabel(provider), error: t("prov.reauthAccountMissing") }), "err"); setLoginInfo(null); finished = true; break; } if (target?.needsReauth) { - notify(t("prov.loginError", { provider: oauthLabel(provider), error: t("prov.reauthIdentityMismatch") }), false); + notify(t("prov.loginError", { provider: oauthLabel(provider), error: t("prov.reauthIdentityMismatch") }), "err"); setLoginInfo(null); finished = true; break; } - notify(t("prov.loginOk", { provider: oauthLabel(provider), cmd: "ocx sync" }), true); + notify(t("prov.loginOk", { provider: oauthLabel(provider), cmd: "ocx sync" }), "ok"); setLoginInfo(null); fetchConfig(); const knownProviders = Object.keys(accountSets); @@ -145,12 +146,12 @@ export function useProvidersOAuth({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider }), }).catch(() => {}); - notify(t("prov.loginTimeout", { provider: oauthLabel(provider) }), false); + notify(t("prov.loginTimeout", { provider: oauthLabel(provider) }), "err"); setLoginInfo(null); } } catch { if (oauthLoginGenerationRef.current!.get(provider) === generation) { - notify(t("prov.loginRequestFail", { provider: oauthLabel(provider) }), false); + notify(t("prov.loginRequestFail", { provider: oauthLabel(provider) }), "err"); } } finally { if (aliveRef.current && oauthLoginGenerationRef.current!.get(provider) === generation) setBusy(null); @@ -161,7 +162,7 @@ export function useProvidersOAuth({ try { const res = await fetch(`${apiBase}/api/oauth/logout?provider=${encodeURIComponent(provider)}`, { method: "POST" }); if (!res.ok) { - notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), false); + notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), "err"); return; } await Promise.all([ @@ -171,9 +172,9 @@ export function useProvidersOAuth({ fetchProviderQuotas(true), ]); bumpModelsRefresh(); - notify(t("prov.logoutOk", { provider: oauthLabel(provider) }), true); + notify(t("prov.logoutOk", { provider: oauthLabel(provider) }), "ok"); } catch { - notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), false); + notify(t("prov.logoutFail", { provider: oauthLabel(provider) }), "err"); } }; diff --git a/gui/src/styles.css b/gui/src/styles.css index 066316aea..5a4c9a526 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -1522,6 +1522,12 @@ dialog.modal-overlay::backdrop { white-space: nowrap; } .codex-auth-page-head__feedback.is-ok { color: var(--green); } +.codex-auth-page-head__feedback.is-warn { + color: var(--amber); + overflow: visible; + text-overflow: clip; + white-space: normal; +} .codex-auth-page-head__feedback.is-err { color: var(--red); } .codex-auth-pause-label { display: inline-block; diff --git a/gui/src/ui.tsx b/gui/src/ui.tsx index d42c0fb93..a4f473677 100644 --- a/gui/src/ui.tsx +++ b/gui/src/ui.tsx @@ -4,6 +4,7 @@ import { createPortal } from "react-dom"; import { IconCheck, IconAlert } from "./icons"; import { IconChevron } from "./icons"; import { computeSelectMenuStyle } from "./select-position"; +import type { NoticeTone } from "./notice-tone"; export function Switch({ on, onClick, disabled, label }: { on: boolean; onClick: () => void; disabled?: boolean; label?: string }) { return ( @@ -14,9 +15,9 @@ export function Switch({ on, onClick, disabled, label }: { on: boolean; onClick: ); } -export function Notice({ tone, children }: { tone: "ok" | "err"; children: ReactNode }) { +export function Notice({ tone, children }: { tone: NoticeTone; children: ReactNode }) { return ( -
+
{tone === "ok" ? : } {children}
diff --git a/gui/tests/add-codex-account-oauth.test.tsx b/gui/tests/add-codex-account-oauth.test.tsx index 5f2a9408c..260812779 100644 --- a/gui/tests/add-codex-account-oauth.test.tsx +++ b/gui/tests/add-codex-account-oauth.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { Window } from "happy-dom"; -import { act, StrictMode, useReducer } from "react"; +import { act, StrictMode, useEffect, useReducer } from "react"; import type { Root } from "react-dom/client"; import { addCodexAccountUiReducer, @@ -8,6 +8,7 @@ import { } from "../src/components/add-codex-account-reducer"; import { useAddCodexAccountOAuth } from "../src/components/use-add-codex-account-oauth"; import { LanguageProvider } from "../src/i18n/provider"; +import type { CodexAccountMutationCompletion } from "../src/codex-account-mutation"; /** * StrictMode reauth latch + login-status single-flight / abort contracts for @@ -95,32 +96,64 @@ afterEach(async () => { await win.happyDOM?.close?.(); }); -function Probe({ reauthAccountId }: { reauthAccountId: string }) { +function Probe({ + reauthAccountId, + onAdded = () => {}, +}: { + reauthAccountId: string; + onAdded?: (completion: CodexAccountMutationCompletion) => void; +}) { const [ui, dispatch] = useReducer( addCodexAccountUiReducer, reauthAccountId, initialAddCodexAccountUiState, ); - useAddCodexAccountOAuth({ + const { bindCallbacks } = useAddCodexAccountOAuth({ apiBase: "", reauthAccountId, ui, dispatch, t: ((key: string) => key) as never, }); + useEffect(() => { + bindCallbacks(onAdded, () => {}); + }, [bindCallbacks, onAdded]); return
; } -async function mountProbe(strict: boolean) { +async function mountProbe( + strict: boolean, + onAdded?: (completion: CodexAccountMutationCompletion) => void, +) { const { createRoot } = await import("react-dom/client"); await act(async () => { root = createRoot(host); - const tree = ; + const tree = ; root.render(strict ? {tree} : tree); }); await act(async () => { await new Promise((r) => setTimeout(r, 40)); }); } +test("completed login forwards only the catalog refresh completion flag", async () => { + const completions: CodexAccountMutationCompletion[] = []; + await mountProbe(false, completion => { completions.push(completion); }); + + await act(async () => { await new Promise((r) => setTimeout(r, 2100)); }); + const holder = statusHolders.shift(); + expect(holder).toBeTruthy(); + await act(async () => { + holder!.resolve(Response.json({ + status: "done", + catalogRefreshPending: true, + accountId: "private-account", + error: "private-error", + })); + await new Promise((r) => setTimeout(r, 30)); + }); + + expect(completions).toEqual([{ catalogRefreshPending: true }]); +}); + test("StrictMode remount clears the reauth latch and starts OAuth again", async () => { await mountProbe(true); await act(async () => { await new Promise((r) => setTimeout(r, 60)); }); diff --git a/gui/tests/codex-account-pool-behaviour.test.tsx b/gui/tests/codex-account-pool-behaviour.test.tsx index f897da44e..94b6056a1 100644 --- a/gui/tests/codex-account-pool-behaviour.test.tsx +++ b/gui/tests/codex-account-pool-behaviour.test.tsx @@ -26,6 +26,7 @@ let nextAccountsResponseGate: Promise | null = null; let pauseResponseActiveId: string | null = null; let bulkPausedAccountIds: string[] = ["a2"]; let bulkResponseActiveId: string | null = null; +let deleteCatalogRefreshPending = false; beforeEach(() => { previous = Object.fromEntries(globals.map((k) => [k, Reflect.get(globalThis, k)])) as typeof previous; @@ -44,12 +45,24 @@ beforeEach(() => { pauseResponseActiveId = null; bulkPausedAccountIds = ["a2"]; bulkResponseActiveId = null; + deleteCatalogRefreshPending = false; accounts = [{ id: "a1", email: "account-one", isMain: true, paused: false, 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}`); + const method = init?.method ?? "GET"; + calls.push(`${method} ${path}`); + if (method === "DELETE" && path.startsWith("codex-auth/accounts?")) { + return { + ok: true, + json: async () => ({ + catalogRefreshPending: deleteCatalogRefreshPending, + accountId: "private-account", + error: "private-error", + }), + } as unknown as Response; + } if (path === "codex-auth/accounts/pause") { const body = JSON.parse(String(init?.body)) as { id: string; paused: boolean }; accounts = accounts.map(account => ( @@ -317,6 +330,19 @@ test("a mutation updates the one shared controller state", async () => { expect(seen.current!.accounts.map(a => a.id)).toEqual(["a1", "a2"]); }); +test("account removal returns only the catalog refresh completion flag", async () => { + deleteCatalogRefreshPending = true; + const seen = await mountController(); + + let result: Awaited> | undefined; + await act(async () => { + result = await seen.current!.removeAccount("a1"); + }); + + expect(result).toEqual({ ok: true, catalogRefreshPending: true }); + expect(calls).toContain("DELETE codex-auth/accounts?id=a1"); +}); + /** * WP2 (260730_gui_hydration_loading_unify/010): the forced quota refresh keeps rows on screen and * deliberately does not touch `loadState`, so `refreshing` is the only signal a surface can use to diff --git a/gui/tests/codex-account-pool-toast-tone.test.tsx b/gui/tests/codex-account-pool-toast-tone.test.tsx index cef08456c..6d60f7c0c 100644 --- a/gui/tests/codex-account-pool-toast-tone.test.tsx +++ b/gui/tests/codex-account-pool-toast-tone.test.tsx @@ -157,3 +157,28 @@ test("successful redeem clears a stale error toast tone", async () => { expect(host.querySelector(".codex-auth-page-head__feedback.is-err")).toBeNull(); expect(host.querySelector(".codex-auth-page-head__feedback.is-ok")).toBeTruthy(); }); + +test("pending model-catalog refresh is shown as a successful mutation warning", async () => { + await mountPool(makeController({ + removeAccount: async () => ({ ok: true, catalogRefreshPending: true }), + })); + + const removeBtn = [...host.querySelectorAll("button")].find((btn) => + (btn.getAttribute("aria-label") ?? "").includes("pool@example.test") + && (btn.getAttribute("aria-label") ?? "").toLowerCase().includes("remove"), + ); + expect(removeBtn).toBeTruthy(); + await act(async () => { removeBtn!.dispatchEvent(new win.MouseEvent("click", { bubbles: true })); }); + await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); + + const warning = host.querySelector(".codex-auth-page-head__feedback.is-warn"); + expect(warning).toBeTruthy(); + expect(warning!.textContent).toContain("ocx sync"); + expect(warning!.textContent).not.toContain("pool@example.test"); + + const styles = await Bun.file(new URL("../src/styles.css", import.meta.url)).text(); + const warningRule = styles.match(/\.codex-auth-page-head__feedback\.is-warn\s*\{([^}]*)\}/)?.[1] ?? ""; + expect(warningRule).toContain("overflow: visible"); + expect(warningRule).toContain("text-overflow: clip"); + expect(warningRule).toContain("white-space: normal"); +}); diff --git a/src/cli/account-auth.ts b/src/cli/account-auth.ts index 79de3e67b..e259d9af2 100644 --- a/src/cli/account-auth.ts +++ b/src/cli/account-auth.ts @@ -11,6 +11,7 @@ import { type CliStdin, type RuntimeApiDeps, } from "./runtime-api"; +import { warnIfCodexCatalogRefreshPending } from "./account-catalog-refresh"; const USAGE = `Usage: ocx account login [--id ] [--reauth] [--code -] [--no-wait] [--json] @@ -105,6 +106,7 @@ async function login(argv: string[], deps: RuntimeApiDeps): Promise { ); if (state.status === "done") { printData(state, wantsJson, [`Logged in${state.email ? ` as ${String(state.email)}` : ""}.`]); + if (!wantsJson) warnIfCodexCatalogRefreshPending(state); return; } if (state.status === "error" || state.status === "expired") { diff --git a/src/cli/account-catalog-refresh.ts b/src/cli/account-catalog-refresh.ts new file mode 100644 index 000000000..ddc98ea44 --- /dev/null +++ b/src/cli/account-catalog-refresh.ts @@ -0,0 +1,12 @@ +export const CODEX_CATALOG_REFRESH_PENDING_WARNING = + "Warning: the account change was saved, but the Codex model catalog refresh is pending. Run 'ocx sync' to retry."; + +export function codexCatalogRefreshPending(value: unknown): boolean { + return value !== null + && typeof value === "object" + && (value as Record).catalogRefreshPending === true; +} + +export function warnIfCodexCatalogRefreshPending(value: unknown): void { + if (codexCatalogRefreshPending(value)) console.error(CODEX_CATALOG_REFRESH_PENDING_WARNING); +} diff --git a/src/cli/account-extended.ts b/src/cli/account-extended.ts index 500d407be..f11c6db92 100644 --- a/src/cli/account-extended.ts +++ b/src/cli/account-extended.ts @@ -11,6 +11,10 @@ import { type AccountDeps, type AccountStdin, type FamilyRows, type ProviderQuotaDto, type ProviderQuotaReportDto, } from "./account-api"; +import { + codexCatalogRefreshPending, + warnIfCodexCatalogRefreshPending, +} from "./account-catalog-refresh"; const MAIN_ID = "__main__"; const AUTO_NOTE = "auto (no pin — lowest-usage account is selected per request)"; @@ -234,18 +238,28 @@ export async function cmdRemove(args: string[], deps: AccountDeps): Promise> = []; let oauthAccounts: Array> = []; let oauthActiveId: string | null = "acct_1"; let oauthLoginStatus: Record = { loggedIn: false }; +let codexLoginStatus: Record = { status: "pending" }; +let codexDeleteCatalogRefreshPending = false; let keyEntries: Array> = []; let keyActiveId: string | null = "key_1"; let logs: string[] = []; @@ -127,7 +129,11 @@ async function mockManagementApi(req: Request): Promise { codexAccounts = codexAccounts.filter(account => account.id !== id); if (activeCodexAccountId === id) activeCodexAccountId = null; lastDeletedType = "codex"; - return json({ ok: true }); + return json({ + ok: true, + catalogRefreshPending: codexDeleteCatalogRefreshPending, + internalError: "private-delete-detail", + }); } if (req.method === "PUT" && url.pathname === "/api/codex-auth/accounts/alias") { @@ -282,6 +288,10 @@ async function mockManagementApi(req: Request): Promise { return json({ ok: true, accepted: true }); } + if (req.method === "GET" && url.pathname === "/api/codex-auth/login-status") { + return json(codexLoginStatus); + } + if (req.method === "POST" && url.pathname === "/api/oauth/login/code") { return json({ ok: true, accepted: true }); } @@ -354,6 +364,8 @@ beforeEach(() => { ]; oauthActiveId = "acct_1"; oauthLoginStatus = { loggedIn: false }; + codexLoginStatus = { status: "pending" }; + codexDeleteCatalogRefreshPending = false; keyEntries = [{ id: "key_1", label: "personal", @@ -732,6 +744,30 @@ describe("ocx account CLI (issue #180 matrix)", () => { )).toBe(true); }); + test("pending Codex removal keeps success and prints generic recovery guidance", async () => { + codexDeleteCatalogRefreshPending = true; + const result = await run(["remove", "openai", "chatgpt_1", "--yes"]); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("auto (no pin"); + expect(result.stderr).toContain("ocx sync"); + expect(result.stderr).toContain("account change was saved"); + expect(result.output).not.toContain("private-delete-detail"); + }); + + test("JSON Codex removal retains the pending flag without a human warning", async () => { + codexDeleteCatalogRefreshPending = true; + const result = await run(["remove", "openai", "chatgpt_1", "--yes", "--json"]); + + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual(expect.objectContaining({ + ok: true, + catalogRefreshPending: true, + })); + expect(result.stdout).not.toContain("private-delete-detail"); + expect(result.stderr).toBe(""); + }); + test("25: removing the active OAuth account reports the promoted account", async () => { const result = await run(["remove", "anthropic", "acct_1", "--yes"]); @@ -954,6 +990,7 @@ describe("ocx account CLI (issue #180 matrix)", () => { id: "chatgpt_1", removedActive: true, promotedActiveId: null, + catalogRefreshPending: false, }); deleteFailure = { status: 500, error: "json delete failed" }; @@ -1298,4 +1335,40 @@ describe("ocx account CLI (issue #180 matrix)", () => { sleepSpy.mockRestore(); } }); + + test("pending Codex login keeps success and prints generic recovery guidance", async () => { + codexLoginStatus = { + status: "done", + catalogRefreshPending: true, + internalError: "private-login-detail", + }; + const sleepSpy = spyOn(Bun, "sleep").mockImplementation(async () => {}); + try { + const result = await run(["login", "openai"]); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("Logged in."); + expect(result.stderr).toContain("ocx sync"); + expect(result.output).not.toContain("private-login-detail"); + } finally { + sleepSpy.mockRestore(); + } + }); + + test("JSON Codex login retains the pending flag without a human warning", async () => { + codexLoginStatus = { status: "done", catalogRefreshPending: true }; + const sleepSpy = spyOn(Bun, "sleep").mockImplementation(async () => {}); + try { + const result = await run(["login", "openai", "--json"]); + + expect(result.code).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + status: "done", + catalogRefreshPending: true, + }); + expect(result.stderr).toBe(""); + } finally { + sleepSpy.mockRestore(); + } + }); }); diff --git a/tests/provider-workspace-auth.test.ts b/tests/provider-workspace-auth.test.ts index 08847c797..f44350fd5 100644 --- a/tests/provider-workspace-auth.test.ts +++ b/tests/provider-workspace-auth.test.ts @@ -123,7 +123,7 @@ describe("workspace account integration seam", () => { // Upstream replaced the setToast state with showActionFeedback; the contract this // pins is unchanged and slightly tighter — the failure path must surface // codexAuth.removeFailed AND mark it as an error tone via the second argument. - expect(codexPool).toContain('showActionFeedback(t("codexAuth.removeFailed"), true)'); + expect(codexPool).toContain('showActionFeedback(t("codexAuth.removeFailed"), "err")'); expect(hook).toContain("pauseTokensRef"); }); From 9a0f860de3e69fbca343c71ff854a553672391c5 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 21:40:59 -0400 Subject: [PATCH 06/19] fix(codex): reserve routing profile namespaces --- .../ja/reference/configuration/providers.md | 4 +- .../ko/reference/configuration/providers.md | 4 +- .../docs/reference/configuration/providers.md | 8 ++-- .../ru/reference/configuration/providers.md | 4 +- .../reference/configuration/providers.md | 2 +- src/codex/account-namespaces.ts | 17 +++++-- src/config.ts | 4 +- src/routing/profile-namespace.ts | 14 ++++++ src/routing/profile.ts | 3 +- tests/codex-account-namespaces.test.ts | 47 +++++++++++++++++++ tests/config.test.ts | 2 + tests/settings-stream-mode.test.ts | 36 +++++++++++++- 12 files changed, 128 insertions(+), 17 deletions(-) create mode 100644 src/routing/profile-namespace.ts diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 28ff87737..e3e69718d 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -31,8 +31,8 @@ selector 名はユーザーが選ぶ公開 label であり、opencodex はアカ `codexAccountNamespaces` のキーは長さ 1〜64 文字、先頭と末尾は ASCII 英数字、内部には英数字、`.`、`_`、`-` を使用でき、予約済み JavaScript object 名は拒否されます。 値は有効な pool account id(内部 `__main__` は不可)、または Codex Desktop アカウントを示す -`"@main"` です。provider と予約済み `openai` / `combo` との衝突は大文字小文字を区別せず検査され、 -namespace 付き combo alias はその namespace prefix に selector を再利用できません。設定済み pool id +`"@main"` です。provider と予約済み `openai` / `combo` / `policy` との衝突は大文字小文字を区別せず検査され、 +namespace 付き combo または routing-profile alias はその namespace prefix に selector を再利用できません。設定済み pool id や他の selector target も selector と再利用できません。raw account id と email は 非公開のままにし、selector を公開名として使ってください。明示的な選択の動作と優先順位は [ルーティング構成](/reference/configuration/routing/)を参照してください。 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 905388534..7a18dd362 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -31,8 +31,8 @@ selector 이름은 사용자가 정하는 공개 label이며, opencodex는 여 `codexAccountNamespaces` 키는 길이가 1~64자이고 시작과 끝은 ASCII 영숫자여야 하며, 내부에는 영숫자, `.`, `_`, `-`를 사용할 수 있습니다. 예약된 JavaScript object 이름은 거부됩니다. 값은 유효한 pool account id(내부 `__main__` 제외)이거나 Codex Desktop 계정을 나타내는 `"@main"`입니다. -provider 및 예약된 `openai` / `combo` 충돌은 대소문자를 구분하지 않고 검사하며, namespace가 있는 -combo alias는 selector를 namespace prefix로 재사용할 수 없습니다. 설정된 pool id와 다른 selector +provider 및 예약된 `openai` / `combo` / `policy` 충돌은 대소문자를 구분하지 않고 검사하며, namespace가 있는 +combo 또는 routing-profile alias는 selector를 namespace prefix로 재사용할 수 없습니다. 설정된 pool id와 다른 selector target도 selector로 재사용할 수 없습니다. raw account id와 email은 비공개로 유지하고 selector를 공개 이름으로 사용하세요. 명시적 선택 동작과 우선순위는 [라우팅 설정](/ko/reference/configuration/routing/)을 참고하십시오. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d1748d54c..e37a0a02b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -32,10 +32,10 @@ Selector names are user-chosen public labels; opencodex assigns no account-role `codexAccountNamespaces` keys are 1–64 characters, starting and ending with an ASCII letter or number, with letters, numbers, `.`, `_`, or `-` inside. Reserved JavaScript object names are rejected. Each value is a valid pool-account id (never internal `__main__`) or `"@main"` -for the Codex Desktop account. Provider and reserved `openai` / `combo` collisions are checked -case-insensitively; a namespaced combo alias cannot reuse a selector as its namespace prefix, and -configured pool ids or selector targets also cannot reuse a selector. Keep raw account ids and -emails private; the selector is the public name. See [Routing Configuration](/reference/configuration/routing/) +for the Codex Desktop account. Provider and reserved `openai` / `combo` / `policy` collisions are +checked case-insensitively; a namespaced combo or routing-profile alias cannot reuse a selector as +its namespace prefix, and configured pool ids or selector targets also cannot reuse a selector. Keep +raw account ids and emails private; the selector is the public name. See [Routing Configuration](/reference/configuration/routing/) for exact-selection behavior and precedence. ## Reserved OpenAI providers diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 3b35dd336..dcdb84316 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -32,8 +32,8 @@ description: Записи провайдеров, аутентификация, аккаунтов. Ключи `codexAccountNamespaces` имеют длину 1–64 символа. Они должны начинаться и заканчиваться ASCII-буквой или цифрой; внутри разрешены буквы, цифры, `.`, `_` и `-`. Зарезервированные имена объектов JavaScript запрещены. Значение — допустимый id аккаунта пула (кроме внутреннего `__main__`) -либо `"@main"` для аккаунта Codex Desktop. Коллизии с provider и зарезервированными `openai` / `combo` -проверяются без учёта регистра; namespace-префикс namespaced combo alias не может повторять селектор. +либо `"@main"` для аккаунта Codex Desktop. Коллизии с provider и зарезервированными `openai` / `combo` / `policy` +проверяются без учёта регистра; namespace-префикс namespaced combo или routing-profile alias не может повторять селектор. Настроенные id пула и цели других селекторов также нельзя повторно использовать как селектор. Сохраняйте raw id аккаунтов и email приватными, а селектор используйте как публичное имя. Поведение и приоритет явного выбора описаны в разделе [Конфигурация маршрутизации](/reference/configuration/routing/). diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index a1a341813..1960e7ca4 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -31,7 +31,7 @@ selector 名称是用户自定的公开 label;opencodex 不会为其赋予账 `codexAccountNamespaces` 的 key 长度为 1–64 个字符,首尾必须是 ASCII 字母或数字, 中间可使用字母、数字、`.`、`_` 或 `-`;保留的 JavaScript object 名称会被拒绝。value 必须是有效的 pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex Desktop 账号。与 provider 及 -保留的 `openai` / `combo` 冲突时不区分大小写;带 namespace 的 combo alias 不能把 selector 复用为 +保留的 `openai` / `combo` / `policy` 冲突时不区分大小写;带 namespace 的 combo 或 routing-profile alias 不能把 selector 复用为 其 namespace prefix,已配置的 pool id 和其他 selector target 也不能复用为 selector。raw account id 与 email 应保持私密,selector 才是公开名称。明确选择的行为和优先级见 [路由配置](/zh-cn/reference/configuration/routing/)。 diff --git a/src/codex/account-namespaces.ts b/src/codex/account-namespaces.ts index 2677fc2ee..bf79ac40a 100644 --- a/src/codex/account-namespaces.ts +++ b/src/codex/account-namespaces.ts @@ -1,6 +1,10 @@ import type { CodexAccount, OcxConfig } from "../types"; import { COMBO_NAMESPACE } from "../combos/types"; import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { + POLICY_NAMESPACE, + routingProfileAliasNamespacePrefixes, +} from "../routing/profile-namespace"; import { CODEX_ACCOUNT_LOG_LABEL_RE, createCodexAccountLogLabel, @@ -21,6 +25,7 @@ const RESERVED_NAMESPACE_KEYS = new Set([ "constructor", COMBO_NAMESPACE, OPENAI_CODEX_PROVIDER_ID, + POLICY_NAMESPACE, ].map(codexProviderNamespaceKey)); const PUBLIC_ACCOUNT_SELECTOR_MAX_ATTEMPTS = 16; @@ -68,17 +73,20 @@ function claimNamespace(requested: string, used: Set): string { return namespace; } -function occupiedNamespaces(config: Pick): Set { +function occupiedNamespaces( + config: Pick, +): Set { return new Set([ ...Object.keys(config.providers).map(codexProviderNamespaceKey), ...comboAliasNamespaces(config), + ...routingProfileAliasNamespacePrefixes(config), ...RESERVED_NAMESPACE_KEYS, ]); } /** Build an initial account-selector map without deriving public selectors from aliases or ids. */ export function defaultCodexAccountNamespaces( - config: Pick, + config: Pick, ): Record { const namespaces: Record = {}; const used = occupiedNamespaces(config); @@ -105,7 +113,10 @@ export function defaultCodexAccountNamespaces( * A true result means the map was mutated in place; callers must persist the updated config. */ export function appendDefaultCodexAccountNamespace( - config: Pick, + config: Pick< + OcxConfig, + "codexAccountNamespaces" | "codexAccounts" | "combos" | "providers" | "routingProfiles" + >, account: Pick, ): boolean { const namespaces = config.codexAccountNamespaces; diff --git a/src/config.ts b/src/config.ts index 07bf72879..90b64e56a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,6 +14,7 @@ import { } from "./codex/account-namespace-match"; import { COMBO_NAMESPACE, comboConfigIssues } from "./combos/types"; import { routingProfileIssues } from "./routing/profile"; +import { POLICY_NAMESPACE } from "./routing/profile-namespace"; import { forgetEphemeralSecretPath, hardenSecretDir, @@ -1035,6 +1036,7 @@ const configSchema = z.object({ const configuredProviderNamespaces = new Set([ COMBO_NAMESPACE, OPENAI_CODEX_PROVIDER_ID, + POLICY_NAMESPACE, ...Object.keys(config.providers), ].map(codexProviderNamespaceKey)); const namespaceTargets = new Set( @@ -1046,7 +1048,7 @@ const configSchema = z.object({ ctx.addIssue({ code: "custom", path: ["codexAccountNamespaces", namespace], - message: "account selectors must not collide with configured provider or combo namespaces", + message: "account selectors must not collide with configured provider, combo, or routing policy namespaces", }); } if (configuredAccountIds.has(namespace) || namespaceTargets.has(namespace)) { diff --git a/src/routing/profile-namespace.ts b/src/routing/profile-namespace.ts new file mode 100644 index 000000000..4f980833b --- /dev/null +++ b/src/routing/profile-namespace.ts @@ -0,0 +1,14 @@ +import type { OcxRoutingProfileConfig } from "../types"; + +export const POLICY_NAMESPACE = "policy"; + +/** Public namespace prefixes claimed by slash-qualified routing-profile aliases. */ +export function routingProfileAliasNamespacePrefixes( + config: { routingProfiles?: Record }, +): string[] { + return Object.values(config.routingProfiles ?? {}).flatMap((profile) => { + const alias = typeof profile?.alias === "string" ? profile.alias.trim() : ""; + const slash = alias.indexOf("/"); + return slash > 0 ? [alias.slice(0, slash)] : []; + }); +} diff --git a/src/routing/profile.ts b/src/routing/profile.ts index bc5ac85c3..0e44d4ecb 100644 --- a/src/routing/profile.ts +++ b/src/routing/profile.ts @@ -13,8 +13,9 @@ import type { import { codexAccountNamespaceEntries } from "../codex/account-namespaces"; import { listComboIds, resolveComboId } from "../combos"; import { hasOwnProvider } from "../config"; +import { POLICY_NAMESPACE } from "./profile-namespace"; -export const POLICY_NAMESPACE = "policy"; +export { POLICY_NAMESPACE }; export const POLICY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/; export const POLICY_ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}(?:\/[A-Za-z0-9][A-Za-z0-9._-]{0,63})?$/; diff --git a/tests/codex-account-namespaces.test.ts b/tests/codex-account-namespaces.test.ts index a035bb66e..079424db7 100644 --- a/tests/codex-account-namespaces.test.ts +++ b/tests/codex-account-namespaces.test.ts @@ -152,6 +152,29 @@ describe("Codex account namespace foundations", () => { expect(namespaces).toEqual({ "main-2": "@main", "p111111-2": "stored-account-id" }); }); + test("avoids routing-profile alias prefixes when allocating defaults", () => { + const namespaces = defaultCodexAccountNamespaces({ + providers: {}, + routingProfiles: { + main: { + alias: "main/gpt-5.5", + candidates: [{ provider: "openai", model: "gpt-5.5" }], + }, + side: { + alias: "p111111/gpt-5.5", + candidates: [{ provider: "openai", model: "gpt-5.5" }], + }, + }, + codexAccounts: [{ + id: "stored-account-id", + logLabel: "p111111", + isMain: false, + }], + }); + + expect(namespaces).toEqual({ "main-2": "@main", "p111111-2": "stored-account-id" }); + }); + test("avoids provider names case-insensitively when allocating defaults", () => { expect(defaultCodexAccountNamespaces({ providers: { @@ -180,6 +203,30 @@ describe("Codex account namespace foundations", () => { expect(appendDefaultCodexAccountNamespace(config, account)).toBe(false); }); + test("append avoids routing-profile alias prefixes without rewriting existing selectors", () => { + const codexAccountNamespaces = { main: "@main" }; + const config = { + providers: {}, + routingProfiles: { + side: { + alias: "p222222/gpt-5.5", + candidates: [{ provider: "openai", model: "gpt-5.5" }], + }, + }, + codexAccountNamespaces, + }; + + expect(appendDefaultCodexAccountNamespace(config, { + id: "new-account-id", + logLabel: "p222222", + isMain: false, + })).toBe(true); + expect(config.codexAccountNamespaces).toEqual({ + main: "@main", + "p222222-2": "new-account-id", + }); + }); + test("refuses to append an account id already owned by a selector key", () => { const codexAccountNamespaces = { work: "existing-account-id", mainAccount: "@main" }; const config = { providers: {}, codexAccountNamespaces }; diff --git a/tests/config.test.ts b/tests/config.test.ts index f66d9f65c..98fd52727 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1592,6 +1592,8 @@ describe("opencodex config defaults", () => { ], ["the combo namespace", { combo: "side-account" }, {}, "must not collide"], ["the combo namespace with different casing", { Combo: "side-account" }, {}, "must not collide"], + ["the routing policy namespace", { policy: "side-account" }, {}, "must not collide"], + ["the routing policy namespace with different casing", { Policy: "side-account" }, {}, "must not collide"], ["the canonical OpenAI namespace with different casing", { OpenAI: "side-account" }, {}, "must not collide"], [ "the canonical OpenAI provider namespace before legacy migration", diff --git a/tests/settings-stream-mode.test.ts b/tests/settings-stream-mode.test.ts index bd65c7b91..0c1a9b5f1 100644 --- a/tests/settings-stream-mode.test.ts +++ b/tests/settings-stream-mode.test.ts @@ -11,7 +11,7 @@ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { getConfigPath, loadConfig, saveConfig } from "../src/config"; +import { getConfigPath, loadConfig, saveConfig, validateConfigCandidate } from "../src/config"; import { handleManagementAPI, type ManagementApiDeps } from "../src/server/management-api"; import { invalidateStartupHealthCache } from "../src/server/startup-health-cache"; import type { OcxConfig } from "../src/types"; @@ -320,6 +320,40 @@ describe("PUT /api/settings", () => { expect(config.codexAccountNamespaces).toEqual({ main: "@main" }); }); + test("account-picker enable avoids routing-profile aliases and persists a reloadable config", async () => { + const config: OcxConfig = { + ...baseConfig(), + routingProfiles: { + fast: { + alias: "main/gpt-test", + candidates: [{ provider: "openai", model: "gpt-test" }], + }, + }, + }; + let persisted: OcxConfig | undefined; + const response = await putSettings(config, { codexAccountPickerEnabled: true }, { + saveConfigPreservingClaudeCode: saved => { + const validation = validateConfigCandidate(saved); + expect(validation.ok).toBe(true); + persisted = structuredClone(saved); + saveConfig(saved); + }, + refreshCodexCatalog: async () => {}, + }); + + expect(response!.status).toBe(200); + expect(await response!.json()).toMatchObject({ + codexAccountPickerEnabled: true, + catalogRefreshPending: false, + }); + expect(persisted?.codexAccountNamespaces).toEqual({ "main-2": "@main" }); + expect(loadConfig()).toMatchObject({ + codexAccountPickerEnabled: true, + codexAccountNamespaces: { "main-2": "@main" }, + routingProfiles: { fast: { alias: "main/gpt-test" } }, + }); + }); + test("account-picker disable does not initialize an empty namespace map", async () => { const config = baseConfig(); let refreshes = 0; From 1f98cb118cc885f65de7e4d32ccded60cffd6ab3 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 23:14:28 -0400 Subject: [PATCH 07/19] docs(codex): clarify exact account routing --- .../content/docs/ja/reference/configuration/providers.md | 6 ++++++ .../content/docs/ko/reference/configuration/providers.md | 6 ++++++ .../src/content/docs/reference/configuration/providers.md | 6 ++++++ .../content/docs/ru/reference/configuration/providers.md | 7 +++++++ .../docs/zh-cn/reference/configuration/providers.md | 6 ++++++ 5 files changed, 31 insertions(+) diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index e3e69718d..5993be167 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -37,6 +37,12 @@ namespace 付き combo または routing-profile alias はその namespace prefi 非公開のままにし、selector を公開名として使ってください。明示的な選択の動作と優先順位は [ルーティング構成](/reference/configuration/routing/)を参照してください。 +exact account-qualified route は mapping されたアカウントに固定され、canonical `openai` が Direct mode +でも別アカウントの credential へ暗黙に fallback しません。unqualified native route では Pool routing が +対象アカウントから選択し、Direct routing は caller の現在の native Codex login だけを使用します。 +`ocx account list`、`ocx account current`、`ocx account use` で Pool state を確認または変更できますが、 +これらの command は exact route の binding を変更しません。 + ## 予約済み OpenAI プロバイダー `openai` および `openai-apikey` は固定予約 ID です。 `openai.codexAccountMode` はデフォルトでは `"pool"` で、メインアカウントと追加アカウント全体を選択します。 `"direct"` は、現在の呼び出し元/メイン ログインのみを使用します。 API は、設定された API キーまたはキー プールのみを使用します。ベア モデルまたは `openai-apikey/` を使用します。クロスルート認証情報のフォールバックはありません。 API GPT-5.6 行は 1,050,000 コンテキスト / 最大 922,000 入力メタデータを伝送し、Pro 仮想 ID は `reasoning.mode: "pro"` を使用してベース ワイヤー モデルに書き換えられます。 diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 7a18dd362..a55e33de5 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -37,6 +37,12 @@ target도 selector로 재사용할 수 없습니다. raw account id와 email은 유지하고 selector를 공개 이름으로 사용하세요. 명시적 선택 동작과 우선순위는 [라우팅 설정](/ko/reference/configuration/routing/)을 참고하십시오. +exact account-qualified route는 mapping된 계정에 고정되며 canonical `openai`가 Direct mode여도 다른 계정의 +credential로 자동 fallback하지 않습니다. unqualified native route에서는 Pool routing이 적격 계정 중에서 +선택하고 Direct routing은 caller의 현재 native Codex login만 사용합니다. `ocx account list`, +`ocx account current`, `ocx account use`로 Pool state를 확인하거나 변경할 수 있지만, 이 command들은 exact +route의 binding을 변경하지 않습니다. + ## 예약된 OpenAI 공급자 `openai`와 `openai-apikey`는 고정 예약 id입니다. `openai.codexAccountMode`의 기본값은 `"pool"`이며, 메인 계정과 추가된 계정 전체에서 선택합니다. `"direct"`는 현재 호출자/메인 로그인만 사용합니다. API는 설정된 API 키 또는 키 풀만 사용합니다. 모델 이름만 쓰거나 `openai-apikey/`을 사용하십시오. 다른 라우트의 자격 증명으로는 대체하지 않습니다. API GPT-5.6 행에는 1,050,000 컨텍스트 / 922,000 최대 입력 메타데이터가 들어가며, Pro 가상 id는 기본 와이어 모델로 다시 쓰면서 `reasoning.mode: "pro"`를 적용합니다. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e37a0a02b..2a66ce947 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -38,6 +38,12 @@ its namespace prefix, and configured pool ids or selector targets also cannot re raw account ids and emails private; the selector is the public name. See [Routing Configuration](/reference/configuration/routing/) for exact-selection behavior and precedence. +An exact account-qualified route stays pinned to its mapped account and never silently falls back to +another account's credential, even when canonical `openai` is in Direct mode. For unqualified native +routes, Pool routing chooses among eligible accounts; Direct routing uses only the caller's current +native Codex login. Use `ocx account list`, `ocx account current`, and `ocx account use` to inspect or +change Pool state; these commands do not change an exact route's binding. + ## Reserved OpenAI providers `openai` and `openai-apikey` are fixed reserved ids. `openai.codexAccountMode` is `"pool"` by default diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index dcdb84316..5cc1a9b99 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -38,6 +38,13 @@ description: Записи провайдеров, аутентификация, raw id аккаунтов и email приватными, а селектор используйте как публичное имя. Поведение и приоритет явного выбора описаны в разделе [Конфигурация маршрутизации](/reference/configuration/routing/). +Exact account-qualified route остаётся закреплённым за сопоставленным аккаунтом и никогда не делает +неявный fallback на credential другого аккаунта, даже когда canonical `openai` работает в Direct mode. +Для unqualified native route Pool routing выбирает среди подходящих аккаунтов, а Direct routing +использует только текущий native Codex login вызывающей стороны. Команды `ocx account list`, +`ocx account current` и `ocx account use` позволяют просматривать или менять Pool state, но не меняют +binding exact route. + ## Зарезервированные провайдеры OpenAI `openai` и `openai-apikey` — это фиксированные зарезервированные id. `openai.codexAccountMode` diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 1960e7ca4..168c911b6 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -36,6 +36,12 @@ pool account id(不能是内部 `__main__`),或用 `"@main"` 表示 Codex 与 email 应保持私密,selector 才是公开名称。明确选择的行为和优先级见 [路由配置](/zh-cn/reference/configuration/routing/)。 +exact account-qualified route 会固定到其 mapping 的账户;即使 canonical `openai` 处于 Direct mode, +也绝不会静默 fallback 到其他账户的 credential。对于 unqualified native route,Pool routing 会从符合 +条件的账户中选择,Direct routing 只使用 caller 当前的 native Codex login。`ocx account list`、 +`ocx account current` 和 `ocx account use` 可用于查看或更改 Pool state,但这些 command 不会改变 exact +route 的 binding。 + ## 保留的 OpenAI 提供者 `openai` 和 `openai-apikey` 是固定的保留 id。`openai.codexAccountMode` 默认是 `"pool"`,会在主账户和新增账户之间选择;`"direct"` 只使用当前调用者/主登录态。API 只使用其配置的 API key 或 key 池。请使用裸模型名或 `openai-apikey/`;不存在跨路由凭据回退。API 的 GPT-5.6 行携带 1,050,000 上下文 / 922,000 最大输入元数据,而 Pro 虚拟 id 会重写为基础线协议模型并带上 `reasoning.mode: "pro"`。 From 6c67a8636729e3921da9a761b8698c44babbd3a0 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Tue, 4 Aug 2026 23:24:31 -0400 Subject: [PATCH 08/19] docs(codex): add catalog sync recovery --- .../src/content/docs/ja/reference/configuration/providers.md | 2 +- .../src/content/docs/ko/reference/configuration/providers.md | 2 +- docs-site/src/content/docs/reference/configuration/providers.md | 2 +- .../src/content/docs/ru/reference/configuration/providers.md | 2 +- .../src/content/docs/zh-cn/reference/configuration/providers.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index 5993be167..ba16e4785 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -17,7 +17,7 @@ description: プロバイダー エントリ、認証、エンドポイント、 | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex プール アカウントのメタデータは Codex Auth によって管理されます。秘密は`codex-accounts.json`に別に住んでいます。 | | `pausedCodexAccountIds?` | `string[]` | `[]` |再開するまでプールの選択から除外されるアカウント (一時停止時のメイン `__main__` アカウントを含む)。 | | `codexAccountNamespaces?` | `Record` | — | 任意の公開 model selector を保存済み Codex アカウント target に対応付ける任意の map。生成 picker row が非表示でも、exact `/` routing はこの map を使用します。picker 表示が有効な場合、target が存在する各 selector の個別 row が追加され、各 row はそのアカウントだけを使い、bare native row は picker で非表示になります。bare native id は Pool / Direct routing を維持し、明示的に無効化されない限り raw `/v1/models` にも残ります。 | -| `codexAccountPickerEnabled?` | `boolean` | 推論 | 生成される account-qualified row の表示だけを制御します。未指定の場合、既存の非空 `codexAccountNamespaces` map は互換性のため表示されます。`true` は row の表示を要求し、map が空なら `PUT /api/settings` が privacy-safe な binding を初期化します。`false` は binding を削除せず row を非表示にし、既存 task や保存済み設定の exact route も無効化しません。 | +| `codexAccountPickerEnabled?` | `boolean` | 推論 | 生成される account-qualified row の表示だけを制御します。未指定の場合、既存の非空 `codexAccountNamespaces` map は互換性のため表示されます。`true` は row の表示を要求し、map が空なら `PUT /api/settings` が privacy-safe な binding を初期化します。`false` は binding を削除せず row を非表示にし、既存 task や保存済み設定の exact route も無効化しません。picker の変更が保存されても catalog refresh が保留の場合は `catalogRefreshPending: true` を返すため、`ocx sync` で再試行してください。 | | `activeCodexAccountId?` | `string` | — |次のリクエスト用に手動で選択されたプール アカウント。選択するとスレッドのアフィニティがクリアされます。実行中のリクエストでは、取得された資格情報が保持されます。 | | `autoSwitchThreshold?` | `number` | `80` | 使用量ベースのプロアクティブ切り替えしきい値。`quota` は紐付け済み/未紐付けタスクの次のリクエストを再評価でき、`fill-first` は未紐付け割り当ての使い切り基準としてのみ使用し、通常の `round-robin` 選択は使用しません。既知の 5 時間、週次、30 日 quota window の最大スコアを使います。`0` は使用量ベースの切り替えだけを無効にし、未紐付け割り当てや障害回復は無効にしません。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新規/未紐付け Codex リクエストの割り当て戦略。live な `(parent thread id, quota scope)` affinity がなければ未紐付けで、プロキシ再起動や affinity リセット後は既存の表示タスクも未紐付けになり得ます。`quota` はアクティブアカウントがなければ既知 usage 最小の適格アカウントを選び、適格なアクティブアカウントが `autoSwitchThreshold` 未満なら維持します。しきい値到達後は、未紐付けリクエストまたは紐付け済みタスクの次のリクエストを usage の低い適格アカウントへ移せます。`round-robin` は未紐付けリクエストを均等分散し、`fill-first` は cooldown、使用不可、または drain threshold までアクティブアカウントへ割り当てます。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index a55e33de5..3d8b6011a 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -17,7 +17,7 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth가 관리하는 ChatGPT/Codex 풀 계정 메타데이터입니다. 비밀 정보는 `codex-accounts.json`에 따로 저장됩니다. | | `pausedCodexAccountIds?` | `string[]` | `[]` | 일시 중지된 `__main__` 계정을 포함해, 재개될 때까지 Pool 선택에서 제외되는 계정입니다. | | `codexAccountNamespaces?` | `Record` | — | 임의의 공개 model selector를 저장된 Codex 계정 target에 연결하는 선택적 map입니다. 생성된 picker row가 숨겨져도 exact `/` routing은 이 map을 사용합니다. picker 표시가 활성화되면 target이 존재하는 각 selector에 별도 row가 생기고, 각 row는 해당 계정만 사용하며 bare native row는 picker에서 숨겨집니다. bare native id는 Pool / Direct routing을 유지하고, 명시적으로 비활성화하지 않는 한 raw `/v1/models`에도 남습니다. | -| `codexAccountPickerEnabled?` | `boolean` | 추론 | 생성된 account-qualified row만 제어합니다. 생략하면 비어 있지 않은 `codexAccountNamespaces` map은 호환성을 위해 계속 표시됩니다. `true`는 이 row들의 표시를 요청하며, map이 비어 있으면 `PUT /api/settings`가 개인정보를 노출하지 않는 binding을 초기화합니다. `false`는 binding을 삭제하지 않고 row를 숨기며, 기존 task와 저장된 설정의 exact route도 비활성화하지 않습니다. | +| `codexAccountPickerEnabled?` | `boolean` | 추론 | 생성된 account-qualified row만 제어합니다. 생략하면 비어 있지 않은 `codexAccountNamespaces` map은 호환성을 위해 계속 표시됩니다. `true`는 이 row들의 표시를 요청하며, map이 비어 있으면 `PUT /api/settings`가 개인정보를 노출하지 않는 binding을 초기화합니다. `false`는 binding을 삭제하지 않고 row를 숨기며, 기존 task와 저장된 설정의 exact route도 비활성화하지 않습니다. picker 변경이 저장되었지만 catalog refresh가 보류되면 `catalogRefreshPending: true`를 반환하므로 `ocx sync`로 다시 시도하세요. | | `activeCodexAccountId?` | `string` | — | 다음 요청에 수동으로 선택한 Pool 계정입니다. 선택하면 thread 결속이 해제되며, 진행 중인 요청은 캡처한 자격 증명을 유지합니다. | | `autoSwitchThreshold?` | `number` | `80` | 사용량 기반 선제 전환 임계값입니다. `quota`는 바인딩된 작업과 바인딩 없는 작업의 다음 요청을 모두 재평가할 수 있고, `fill-first`는 바인딩 없는 작업 배정의 소진 기준으로만 사용하며, 기본 `round-robin` 선택은 이 값을 사용하지 않습니다. 알려진 5시간, 주간, 30일 quota window 중 가장 높은 점수를 씁니다. `0`은 사용량 기반 전환만 끄며 바인딩 없는 작업 배정이나 실패 복구는 끄지 않습니다. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 새 작업/바인딩 없는 Codex 요청의 계정 배정 전략입니다. `(parent thread id, quota scope)`의 live affinity가 없으면 바인딩 없는 요청이며, 프록시 재시작이나 affinity 초기화 뒤에는 기존에 보이던 작업도 바인딩이 없어질 수 있습니다. `quota`는 활성 계정이 없을 때 알려진 usage가 가장 낮은 적격 계정을 선택하고, 적격 활성 계정이 `autoSwitchThreshold` 미만이면 유지합니다. 임계값 도달 뒤에는 바인딩 없는 요청이나 바인딩된 작업의 다음 요청을 usage가 더 낮은 적격 계정으로 옮길 수 있습니다. `round-robin`은 바인딩 없는 요청을 균등 분배하고, `fill-first`는 cooldown, 사용 불가 또는 drain threshold까지 활성 계정에 배정합니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 2a66ce947..c115a198b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -18,7 +18,7 @@ authenticated. | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by Codex Auth. Secrets live separately in `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Accounts excluded from Pool selection until resumed, including the main `__main__` account when paused. | | `codexAccountNamespaces?` | `Record` | — | Optional map from an arbitrary public model selector to a stored Codex account target. Exact `/` routing uses this map even when its generated picker rows are hidden. When picker visibility is enabled, each selector whose target is present adds separate rows that use only that account, and bare native rows are hidden from the picker. Bare native ids retain Pool/Direct routing and remain listed by raw `/v1/models` unless explicitly disabled. | -| `codexAccountPickerEnabled?` | `boolean` | inferred | Controls generated account-qualified rows only. When omitted, a non-empty `codexAccountNamespaces` map remains visible for compatibility. `true` requests those rows; `PUT /api/settings` initializes privacy-safe bindings when the map is empty. `false` hides the rows without deleting bindings or disabling exact routes in existing tasks and saved settings. | +| `codexAccountPickerEnabled?` | `boolean` | inferred | Controls generated account-qualified rows only. When omitted, a non-empty `codexAccountNamespaces` map remains visible for compatibility. `true` requests those rows; `PUT /api/settings` initializes privacy-safe bindings when the map is empty. `false` hides the rows without deleting bindings or disabling exact routes in existing tasks and saved settings. A persisted picker change can return `catalogRefreshPending: true`; retry with `ocx sync`. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account for the next request. Selection clears thread affinity; in-flight requests keep captured credentials. | | `autoSwitchThreshold?` | `number` | `80` | Usage threshold for proactive switching. `quota` can re-evaluate both bound and unbound tasks on their next request; `fill-first` uses it only as the drain point for unbound assignment; normal `round-robin` selection does not use it. The score uses the hottest known 5h, weekly, or 30d quota window. `0` disables usage-based proactive switching only, not unbound assignment or failure recovery. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Assignment strategy for new/unbound Codex requests. A request is unbound when it has no live (parent thread id, quota scope) affinity; a visible existing task can become unbound after proxy restart or affinity reset. `quota` picks the lowest-usage eligible account when no active account exists, keeps an eligible active account below `autoSwitchThreshold`, and after the threshold may move an unbound request or proactively rebind a bound task to a lower-usage eligible account. `round-robin` distributes unbound requests evenly; `fill-first` keeps assigning unbound requests to the active account until cooldown, unavailability, or the configured drain threshold. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index 5cc1a9b99..f34ae19fd 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -18,7 +18,7 @@ description: Записи провайдеров, аутентификация, | `codexAccounts?` | `CodexAccount[]` | `[]` | Метаданные аккаунтов пула ChatGPT/Codex, которыми управляет Codex Auth. Секреты живут отдельно в `codex-accounts.json`. | | `pausedCodexAccountIds?` | `string[]` | `[]` | Аккаунты, исключённые из выбора Pool до снятия паузы, включая основной аккаунт `__main__`, если он поставлен на паузу. | | `codexAccountNamespaces?` | `Record` | — | Необязательное сопоставление произвольного публичного селектора модели с сохранённым аккаунтом Codex. Exact routing `/` использует эту map, даже если созданные picker-row скрыты. Когда видимость picker включена, каждый selector с существующей целью добавляет отдельные row, каждая из которых использует только сопоставленный аккаунт, а bare native-row скрываются в picker. Bare native-id сохраняют Pool / Direct routing и остаются в raw `/v1/models`, если не отключены явно. | -| `codexAccountPickerEnabled?` | `boolean` | выводится | Управляет только созданными account-qualified row. Если поле опущено, непустая map `codexAccountNamespaces` остаётся видимой для совместимости. `true` запрашивает эти row; если map пуста, `PUT /api/settings` создаёт privacy-safe binding. `false` скрывает row, но не удаляет binding и не отключает exact route в существующих task и сохранённых настройках. | +| `codexAccountPickerEnabled?` | `boolean` | выводится | Управляет только созданными account-qualified row. Если поле опущено, непустая map `codexAccountNamespaces` остаётся видимой для совместимости. `true` запрашивает эти row; если map пуста, `PUT /api/settings` создаёт privacy-safe binding. `false` скрывает row, но не удаляет binding и не отключает exact route в существующих task и сохранённых настройках. Если изменение picker сохранено, но catalog refresh ещё ожидается, ответ содержит `catalogRefreshPending: true`; повторите через `ocx sync`. | | `activeCodexAccountId?` | `string` | — | Вручную выбранный аккаунт Pool для следующего запроса. Выбор очищает thread affinity; in-flight-запросы сохраняют уже захваченные credential'ы. | | `autoSwitchThreshold?` | `number` | `80` | Порог проактивного переключения по использованию. `quota` может повторно оценить следующий запрос как привязанной, так и непривязанной задачи; `fill-first` использует его только как точку исчерпания для непривязанных назначений; обычный `round-robin` его не использует. Оценка берёт самое горячее из окон 5 часов, недели и 30 дней. `0` отключает только переключение по использованию, но не назначение непривязанных задач и не восстановление после сбоев. | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | Стратегия назначения для новых/непривязанных запросов Codex. Запрос непривязан, если у него нет live affinity `(parent thread id, quota scope)`; видимая существующая задача может стать непривязанной после перезапуска прокси или сброса affinity. `quota` выбирает подходящий аккаунт с наименьшим известным usage, когда активного аккаунта нет, сохраняет подходящий активный аккаунт ниже `autoSwitchThreshold`, а после порога может перевести непривязанный запрос или следующий запрос привязанной задачи на подходящий аккаунт с меньшим usage. `round-robin` равномерно распределяет непривязанные запросы; `fill-first` назначает их активному аккаунту до cooldown, недоступности или порога исчерпания. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 168c911b6..e28d89eac 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -17,7 +17,7 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 | `codexAccounts?` | `CodexAccount[]` | `[]` | 由 Codex Auth 管理的 ChatGPT/Codex 池账户元数据。密钥单独存放在 `codex-accounts.json` 中。 | | `pausedCodexAccountIds?` | `string[]` | `[]` | 在恢复之前从 Pool 选择中排除的账户,包括被暂停时的主 `__main__` 账户。 | | `codexAccountNamespaces?` | `Record` | — | 将任意公开 model selector 映射到已保存 Codex account target 的可选配置。即使生成的 picker row 被隐藏,exact `/` routing 仍会使用该 map。启用 picker 可见性时,target 存在的每个 selector 都会添加独立 row,每个 row 只使用对应账户,bare native row 则会在 picker 中隐藏。bare native id 保留 Pool / Direct routing,除非显式禁用,仍会列在 raw `/v1/models` 中。 | -| `codexAccountPickerEnabled?` | `boolean` | 推断 | 仅控制生成的 account-qualified row。省略时,非空 `codexAccountNamespaces` map 为了兼容性仍会显示。`true` 表示要显示这些 row;如果 map 为空,`PUT /api/settings` 会初始化隐私安全的 binding。`false` 会隐藏这些 row,但不会删除 binding,也不会禁用已有 task 和已保存设置的 exact route。 | +| `codexAccountPickerEnabled?` | `boolean` | 推断 | 仅控制生成的 account-qualified row。省略时,非空 `codexAccountNamespaces` map 为了兼容性仍会显示。`true` 表示要显示这些 row;如果 map 为空,`PUT /api/settings` 会初始化隐私安全的 binding。`false` 会隐藏这些 row,但不会删除 binding,也不会禁用已有 task 和已保存设置的 exact route。picker 变更已持久化但 catalog refresh 仍待处理时会返回 `catalogRefreshPending: true`;请运行 `ocx sync` 重试。 | | `activeCodexAccountId?` | `string` | — | 为下一次请求手动选定的 Pool 账户。选择会清除线程亲和性;进行中的请求会保留捕获到的凭据。 | | `autoSwitchThreshold?` | `number` | `80` | 基于用量的主动切换阈值。`quota` 可在下一次请求中重新评估已绑定和未绑定任务;`fill-first` 仅把它用作未绑定分配的耗尽点;正常 `round-robin` 不使用它。分数取已知 5 小时、周或 30 天 quota window 的最高值。`0` 只关闭基于用量的主动切换,不关闭未绑定任务分配或故障恢复。 | | `accountPoolStrategy?` | `"quota" \| "round-robin" \| "fill-first"` | `"quota"` | 新建/未绑定 Codex 请求的分配策略。没有 live `(parent thread id, quota scope)` affinity 的请求属于未绑定;代理重启或 affinity 重置后,已有可见任务也可能未绑定。`quota` 在没有活跃账号时选择已知 usage 最低的合格账号;活跃账号合格且低于 `autoSwitchThreshold` 时继续使用;达到阈值后,可把未绑定请求或已绑定任务的下一次请求切换到 usage 更低的合格账号。`round-robin` 均匀分配未绑定请求;`fill-first` 在 cooldown、不可用或耗尽阈值前持续分配给活跃账号。 | From 49d646bb148e24145cefaa7ef929c828dfb17d99 Mon Sep 17 00:00:00 2001 From: chrisae9 Date: Wed, 5 Aug 2026 01:06:03 -0400 Subject: [PATCH 09/19] fix(codex): harden picker config recovery --- .../ja/reference/cli/providers-accounts.md | 7 ++- .../ko/reference/cli/providers-accounts.md | 7 ++- .../docs/reference/cli/providers-accounts.md | 9 +++- .../ru/reference/cli/providers-accounts.md | 10 +++- .../zh-cn/reference/cli/providers-accounts.md | 9 +++- src/config.ts | 32 +++++++++++- structure/03_catalog-and-subagents.md | 7 ++- structure/08_openai-provider-tiers.md | 5 +- tests/config.test.ts | 52 +++++++++++++++++-- 9 files changed, 120 insertions(+), 18 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md index da47276f0..53fee08b4 100644 --- a/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md +++ b/docs-site/src/content/docs/ja/reference/cli/providers-accounts.md @@ -156,16 +156,21 @@ OAuth プロバイダーと API キー プロバイダーの場合、これに ### `ocx account login|reauth|code|cancel ...` ヘッドレス シェルからブラウザベースまたは手動コードのアカウント認証を実行します。プロバイダー固有のコマンド形式には `ocx account --help` を使用します。 +Codex ログインは catalog refresh が保留中でも保存済みです。通常出力では `ocx sync` で再試行するよう警告し、 +`--json` の login-status object には代わりに `catalogRefreshPending: true` が含まれる場合があります。 ### `ocx account remove --yes [--json]` この保護された非対話型削除には `--yes` が必要です。削除する前に、ID が存在することが確認されます。 ID が欠落している場合は、DELETE を送信せずに 1 が終了します。メインの Codex App ログインは削除できないため、`remove openai main --yes` は拒否されます。削除後、ファミリーは再度読み取られます。固定された Codex アカウントを削除すると、ピンがクリアされ、自動選択に戻ります。 OAuth は最初に残ったアカウントを昇格させるか、何も報告しません。 API キー プールは、最初に残っているキーを昇格するか、何も報告しません。 `--json` の成功と失敗の形状は次のとおりです。 ```text -{ ok: true, provider, id, removedActive: boolean, promotedActiveId: string | null } +{ ok: true, provider, id, removedActive: boolean, promotedActiveId: string | null, catalogRefreshPending?: boolean } { error: string } // stderr, exit 1 ``` +`catalogRefreshPending` は Codex の削除でのみ存在します。`true` でも削除自体は保存済みです。 +通常出力では `ocx sync` で catalog update を再試行するよう警告します。 + ### `ocx account add-key [--label