From 5bcbd291abdfc0bab434d5faeb8914b686317c21 Mon Sep 17 00:00:00 2001 From: Alvin0412 Date: Sun, 26 Jul 2026 16:56:57 +0800 Subject: [PATCH 1/3] feat(codex): reserve main account as last resort --- README.md | 4 + .../content/docs/reference/configuration.md | 1 + .../docs/zh-cn/reference/configuration.md | 1 + readme/README.zh-CN.md | 3 + src/codex/auth-api.ts | 2 +- src/codex/routing.ts | 32 +++++- src/config.ts | 1 + src/types.ts | 2 + structure/08_openai-provider-tiers.md | 7 ++ tests/codex-main-rotation.test.ts | 108 ++++++++++++++++++ tests/codex-routing.test.ts | 2 +- tests/config.test.ts | 23 ++++ 12 files changed, 181 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 66808ae6b4..43fff4d64d 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,10 @@ next Codex session. opencodex keeps these behaviors: - **New sessions can auto-route.** When auto-switch is enabled, opencodex compares the hottest known quota window across 5h, weekly, and 30d usage, then picks a lower-usage eligible account for new sessions once the active account crosses the threshold. +- **The main login can be reserved for last.** Set `mainAccountLastResort` to `true` to keep new or + re-bound work on added pool accounts while any is selectable. Quota rebalancing stays on added + accounts; cooldown and failure paths try the remaining added accounts before using the main login. + An explicit manual account selection still applies immediately. - **Quota lookup is built in.** The dashboard can refresh all account quotas in one click, and the request log labels pool traffic with non-PII account ordinals. - **Failures fail closed.** Token failures mark reauthentication instead of falling back to another diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 122253b742..d9db7fa848 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -53,6 +53,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | +| `mainAccountLastResort?` | `boolean` | `false` | Keep automatic routing on added Pool accounts while any are selectable, using the main Codex login only as the final fallback. Explicit manual selection still applies immediately; existing thread affinity and normal cooldown/failure rules still apply. | | `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient upstream failures before future new sessions fail over to another eligible pool account. Set `0` to disable failure failover. | | `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache (5 min). | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 17f07ee08f..63bdc26565 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -50,6 +50,7 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | | `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | +| `mainAccountLastResort?` | `boolean` | `false` | 只要仍有已添加的 pool 账户可选,自动路由就继续使用这些账户,主 Codex 登录仅作为最后 fallback。显式手动选择仍会立即生效;已有 thread affinity 及正常冷却/失败规则保持不变。 | | `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次临时上游失败后,让后续新 session failover 到其他合格 pool account。设为 `0` 可禁用失败切换。 | | `modelCacheTtlMs?` | `number` | `300000` | 每个 provider 的 `/models` 缓存新鲜度窗口(5 分钟)。 | diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index b4ef7a8de1..ef036e4298 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -212,6 +212,9 @@ opencodex 保持两种独立行为: 会继续使用同一账户。 - **新会话可自动路由。** 启用自动切换后,opencodex 比较 5 小时、每周、30 天使用量中最热的配额窗口, 当活跃账户越过阈值时,为新会话挑选使用量更低的合格账户。 +- **主登录可保留到最后。** 将 `mainAccountLastResort` 设为 `true` 后,只要还有已添加的 pool 账户可选, + 新建或重新绑定的工作就不会使用主登录。配额重平衡只在已添加账户之间进行;冷却与失败路径会先尝试其余 + 已添加账户,最后才使用主登录。显式手动选择账号仍会立即生效。 - **内置配额查询。** 仪表盘可一键刷新所有账户配额,请求日志用非 PII 的账户序号标记池流量。 - **失败即 fail-closed。** token 失败会标记需重新认证,而不是悄悄回退到另一个凭证;429 配额响应会让账户 进入冷却,并可将后续工作 failover 到另一个合格的池账户。 diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 5d1fba5da3..e00d1a42fe 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -607,7 +607,7 @@ export async function handleCodexAuthAPI( if (!exists) return jsonResponse({ error: "Account not found" }, 400); } runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; - resetCodexRoutingForManualSelection(body.accountId ?? MAIN_CODEX_ACCOUNT_ID); + resetCodexRoutingForManualSelection(runtimeConfig, body.accountId ?? MAIN_CODEX_ACCOUNT_ID); saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true }); } diff --git a/src/codex/routing.ts b/src/codex/routing.ts index 645f4c59b7..ad5b9ddaef 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -24,6 +24,7 @@ export type CodexThreadResolution = | { status: "expired"; accountId: string }; const threadAccountMap = new Map(); +const manualAccountSelections = new WeakMap(); type CodexUpstreamHealth = { consecutiveFailures: number; /** Consecutive healthy terminals observed while recovering from escalation level 2+. */ @@ -306,8 +307,9 @@ function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Parti } /** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */ -export function resetCodexRoutingForManualSelection(accountId: string): void { +export function resetCodexRoutingForManualSelection(config: OcxConfig, accountId: string): void { clearThreadAccountMap(); + manualAccountSelections.set(config, accountId); const current = upstreamHealth.get(accountId); if (!current) return; const preserved = preservedCooldownFields(current); @@ -449,7 +451,8 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da // The main Codex account is not stored in config.codexAccounts; include it as a // first-class rotation candidate when its read-only token is usable (Option A). if ( - excludeId !== MAIN_CODEX_ACCOUNT_ID + (!config.mainAccountLastResort || ids.length === 0) + && excludeId !== MAIN_CODEX_ACCOUNT_ID && !isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) && !isCodexAccountInCooldown(MAIN_CODEX_ACCOUNT_ID, now) && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now) @@ -460,6 +463,13 @@ function getEligiblePoolAccounts(config: OcxConfig, excludeId?: string, now = Da return ids; } +function getQuotaAutoSwitchCandidates(config: OcxConfig, active: string, now: number): string[] { + const candidates = getEligiblePoolAccounts(config, active, now); + return config.mainAccountLastResort && active !== MAIN_CODEX_ACCOUNT_ID + ? candidates.filter(id => id !== MAIN_CODEX_ACCOUNT_ID) + : candidates; +} + export function getPoolAccountPlan(config: OcxConfig, accountId: string): string | undefined { if (accountId === MAIN_CODEX_ACCOUNT_ID) return getMainAccountPlan(); return (config.codexAccounts ?? []).find(account => !account.isMain && account.id === accountId)?.plan; @@ -468,7 +478,7 @@ export function getPoolAccountPlan(config: OcxConfig, accountId: string): string function pickLowerUsageAccount(config: OcxConfig, active: string, activeUsage: number, now: number): string { let best = active; let bestUsage = activeUsage; - for (const id of getEligiblePoolAccounts(config, active, now)) { + for (const id of getQuotaAutoSwitchCandidates(config, active, now)) { const usage = computeCodexUsageScore(getAccountQuota(id), getPoolAccountPlan(config, id)); if (usage < bestUsage) { best = id; @@ -491,8 +501,18 @@ export function pickLowestUsageCodexAccount(config: OcxConfig, excludeId?: strin return best; } +function preferAddedPoolAccount(config: OcxConfig, active: string, now: number): string { + if ( + !config.mainAccountLastResort + || active !== MAIN_CODEX_ACCOUNT_ID + || manualAccountSelections.get(config) === active + ) return active; + return pickLowestUsageCodexAccount(config, MAIN_CODEX_ACCOUNT_ID, now) ?? active; +} + function setActiveCodexAccount(config: OcxConfig, accountId: string): void { if (config.activeCodexAccountId === accountId) return; + manualAccountSelections.delete(config); config.activeCodexAccountId = accountId; saveConfigPreservingClaudeCode(config); } @@ -587,6 +607,7 @@ export function previewCodexAccountForRequest( if (!active) { return pickLowestUsageCodexAccount(config, undefined, now); } + active = preferAddedPoolAccount(config, active, now); if (!isCodexAccountSelectable(config, active, now)) { const fallback = pickLowestUsageCodexAccount(config, active, now); if (fallback) active = fallback; @@ -677,6 +698,11 @@ export function resolveCodexAccountForThreadDetailed( return { status: "none" }; } } + const preferred = preferAddedPoolAccount(config, active, now); + if (preferred !== active) { + setActiveCodexAccount(config, preferred); + active = preferred; + } active = applyQuotaAutoSwitch(config, active, now); active = applyFailureFailover(config, active, now); if (!isCodexAccountUsable(config, active)) { diff --git a/src/config.ts b/src/config.ts index f9da2927d5..739be586fd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -488,6 +488,7 @@ const configSchema = z.object({ codexShimAutoRestore: z.boolean().optional(), // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), + mainAccountLastResort: z.boolean().optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole // parse: a hand-edited typo must never trip the backup-and-defaults repair // path below and wipe providers/pool accounts. Warning emitted in loadConfig. diff --git a/src/types.ts b/src/types.ts index 76fdd5b3ad..fa6ba1b5b5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -642,6 +642,8 @@ export interface OcxConfig { codexAccounts?: CodexAccount[]; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; + /** Use the main Codex login only when no added pool account is selectable. Default false. */ + mainAccountLastResort?: boolean; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ autoSwitchThreshold?: number; /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */ diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index a0da134877..f1610fd5c5 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,6 +18,13 @@ engine. Direct short-circuits that engine before pool state is read or mutated a current caller/main-login bearer. Neither mode may fall through to `openai-apikey`, and the API provider may not fall through to Codex-login credentials. +Pool selection treats the main login and added accounts equally by default. The opt-in +`mainAccountLastResort` policy keeps new or re-bound work on selectable added accounts, excludes +the main login from quota rebalancing away from an added account, and permits failure/cooldown +failover to the main login only after no other added account is selectable. Existing thread +affinity remains authoritative until its normal re-evaluation, expiry, or failure path runs. An +explicit manual account selection applies immediately until normal quota or failure routing changes it. + ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option openai-apikey/gpt-5.6-sol # OpenAI API key diff --git a/tests/codex-main-rotation.test.ts b/tests/codex-main-rotation.test.ts index 1c79d5b244..041bb80684 100644 --- a/tests/codex-main-rotation.test.ts +++ b/tests/codex-main-rotation.test.ts @@ -2,12 +2,15 @@ import { describe, expect, test, beforeEach, afterEach } from "bun:test"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS, clearThreadAccountMap, clearCodexUpstreamHealth, formatCodexProviderForLog, isCodexAccountInCooldown, pickLowestUsageCodexAccount, + previewCodexAccountForRequest, recordCodexUpstreamOutcome, + resetCodexRoutingForManualSelection, resolveCodexAccountForThread, } from "../src/codex/routing"; import { @@ -137,6 +140,111 @@ describe("main account rotation (Option A)", () => { expect(pickLowestUsageCodexAccount(config, MAIN_CODEX_ACCOUNT_ID)).toBe("b"); }); + test("last-resort policy keeps main out while an added account is selectable", () => { + const config = makeConfig({ + activeCodexAccountId: undefined, + autoSwitchThreshold: 0, + mainAccountLastResort: true, + }); + updateAccountQuota("a", 80, 0); + updateAccountQuota("b", 40, 0); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5, 0); + + expect(pickLowestUsageCodexAccount(config)).toBe("b"); + expect(previewCodexAccountForRequest(null, config)).toBe("b"); + expect(resolveCodexAccountForThread("last-resort-new", config)).toBe("b"); + expect(config.activeCodexAccountId).toBe("b"); + }); + + test("last-resort policy does not quota-switch an added account to main", () => { + const now = 1_800_000_000_000; + const config = makeConfig({ + codexAccounts: [{ id: "a", email: "a@test", isMain: false }], + mainAccountLastResort: true, + }); + updateAccountQuota("a", 90, 0); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5, 0); + + expect(resolveCodexAccountForThread("last-resort-hot", config, now)).toBe("a"); + expect(previewCodexAccountForRequest(null, config, now + 1)).toBe("a"); + expect(resolveCodexAccountForThread( + "last-resort-hot", + config, + now + CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS + 1, + )).toBe("a"); + expect(config.activeCodexAccountId).toBe("a"); + }); + + test("last-resort policy preserves an explicit manual main selection until failure", () => { + const config = makeConfig({ + activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, + autoSwitchThreshold: 0, + mainAccountLastResort: true, + }); + updateAccountQuota("a", 20, 0); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5, 0); + + resetCodexRoutingForManualSelection(config, MAIN_CODEX_ACCOUNT_ID); + + expect(previewCodexAccountForRequest(null, config)).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(resolveCodexAccountForThread("manual-main", config)).toBe(MAIN_CODEX_ACCOUNT_ID); + + recordCodexUpstreamOutcome(config, MAIN_CODEX_ACCOUNT_ID, 429, { retryAfter: "60" }); + expect(config.activeCodexAccountId).toBe("a"); + expect(resolveCodexAccountForThread("after-manual-main-failure", config)).toBe("a"); + }); + + test("last-resort quota failover exhausts added accounts before main", () => { + const config = makeConfig({ autoSwitchThreshold: 0, mainAccountLastResort: true }); + const now = 1_800_000_000_000; + updateAccountQuota("a", 10, 0); + updateAccountQuota("b", 50, 0); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5, 0); + + recordCodexUpstreamOutcome(config, "a", 429, { retryAfter: "60", now }); + expect(config.activeCodexAccountId).toBe("b"); + + recordCodexUpstreamOutcome(config, "b", 429, { retryAfter: "60", now: now + 1 }); + expect(config.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + + test("last-resort transient failover exhausts added accounts before main", () => { + const config = makeConfig({ + autoSwitchThreshold: 0, + upstreamFailoverThreshold: 1, + mainAccountLastResort: true, + }); + const now = 1_800_000_000_000; + updateAccountQuota("a", 10, 0); + updateAccountQuota("b", 50, 0); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5, 0); + + recordCodexUpstreamOutcome(config, "a", 502, { now }); + expect(resolveCodexAccountForThread("last-resort-transient-a", config, now + 1)).toBe("b"); + + recordCodexUpstreamOutcome(config, "b", 502, { now: now + 2 }); + expect(resolveCodexAccountForThread("last-resort-transient-b", config, now + 3)).toBe(MAIN_CODEX_ACCOUNT_ID); + }); + + test("last-resort policy returns new threads to a recovered added account", () => { + const config = makeConfig({ + activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID, + autoSwitchThreshold: 0, + mainAccountLastResort: true, + }); + markAccountNeedsReauth("a"); + markAccountNeedsReauth("b"); + + expect(resolveCodexAccountForThread("main-existing", config)).toBe(MAIN_CODEX_ACCOUNT_ID); + + clearAccountNeedsReauth("b"); + updateAccountQuota("b", 40, 0); + expect(resolveCodexAccountForThread("main-existing", config)).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(previewCodexAccountForRequest(null, config)).toBe("b"); + expect(resolveCodexAccountForThread("managed-new", config)).toBe("b"); + expect(config.activeCodexAccountId).toBe("b"); + }); + test("main is excluded from rotation candidates when its token is missing", () => { rmSync(join(CODEX_DIR, "auth.json")); const config = makeConfig(); diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index 8b3b5cbbb8..207db5ff60 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -818,7 +818,7 @@ describe("codex routing", () => { expect(isCodexAccountSoftAvoided("b", now + 4)).toBe(true); config.activeCodexAccountId = "b"; - resetCodexRoutingForManualSelection("b"); + resetCodexRoutingForManualSelection(config, "b"); expect(isCodexAccountSoftAvoided("b", now + 4)).toBe(false); expect(isCodexAccountInCooldown("b", now + 4)).toBe(true); expect(getCodexUpstreamHealth("b")?.consecutiveFailures).toBe(0); diff --git a/tests/config.test.ts b/tests/config.test.ts index cfd179caa0..9dd2fa9885 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -144,6 +144,29 @@ describe("opencodex config defaults", () => { } }); + test("mainAccountLastResort loads booleans and rejects other values", () => { + const base = { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + }; + writeConfig({ ...base, mainAccountLastResort: true }); + expect(readConfigDiagnostics().config.mainAccountLastResort).toBe(true); + + for (const invalid of [null, "true", 1]) { + writeConfig({ ...base, mainAccountLastResort: invalid }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("mainAccountLastResort"); + } + }); + test("multi-agent guidance is default-on and false is the only off state", () => { expect(getDefaultConfig().multiAgentGuidanceEnabled).toBe(true); expect(multiAgentGuidanceEnabled({})).toBe(true); From 6e3d519d8a1dae85d5c7bc47b8bf1f7f31c38381 Mon Sep 17 00:00:00 2001 From: Alvin0412 Date: Mon, 27 Jul 2026 03:01:10 +0800 Subject: [PATCH 2/3] fix(codex): persist manual account selection --- README.md | 2 +- .../content/docs/reference/configuration.md | 3 +- .../docs/zh-cn/reference/configuration.md | 3 +- readme/README.zh-CN.md | 2 +- src/codex/account-lifecycle.ts | 1 + src/codex/auth-api.ts | 2 +- src/codex/routing.ts | 19 +++++---- src/config.ts | 1 + src/types.ts | 2 + structure/08_openai-provider-tiers.md | 3 +- tests/codex-auth-api.test.ts | 3 ++ tests/codex-main-rotation.test.ts | 42 +++++++++++++++++++ tests/config.test.ts | 23 ++++++++++ 13 files changed, 91 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 43fff4d64d..a4a1925ce9 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ next Codex session. opencodex keeps these behaviors: - **The main login can be reserved for last.** Set `mainAccountLastResort` to `true` to keep new or re-bound work on added pool accounts while any is selectable. Quota rebalancing stays on added accounts; cooldown and failure paths try the remaining added accounts before using the main login. - An explicit manual account selection still applies immediately. + An explicit manual account selection applies immediately and survives proxy restarts. - **Quota lookup is built in.** The dashboard can refresh all account quotas in one click, and the request log labels pool traffic with non-PII account ordinals. - **Failures fail closed.** Token failures mark reauthentication instead of falling back to another diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index d9db7fa848..34c062f4a8 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -53,7 +53,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | | `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | -| `mainAccountLastResort?` | `boolean` | `false` | Keep automatic routing on added Pool accounts while any are selectable, using the main Codex login only as the final fallback. Explicit manual selection still applies immediately; existing thread affinity and normal cooldown/failure rules still apply. | +| `manualCodexAccountSelectionId?` | `string` | — | Internal provenance marker for an explicit account selection. It survives proxy restarts and is cleared when automatic quota or failure routing changes the active account. Do not set by hand. | +| `mainAccountLastResort?` | `boolean` | `false` | Keep automatic routing on added Pool accounts while any are selectable, using the main Codex login only as the final fallback. Explicit manual selection applies immediately and survives proxy restarts; existing thread affinity and normal cooldown/failure rules still apply. | | `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient upstream failures before future new sessions fail over to another eligible pool account. Set `0` to disable failure failover. | | `modelCacheTtlMs?` | `number` | `300000` | Freshness window for the per-provider `/models` cache (5 min). | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index 63bdc26565..b4bbb2cff8 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -50,7 +50,8 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | | `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | -| `mainAccountLastResort?` | `boolean` | `false` | 只要仍有已添加的 pool 账户可选,自动路由就继续使用这些账户,主 Codex 登录仅作为最后 fallback。显式手动选择仍会立即生效;已有 thread affinity 及正常冷却/失败规则保持不变。 | +| `manualCodexAccountSelectionId?` | `string` | — | 显式手动选择的内部来源标记。它会跨代理重启保留,并在自动配额或失败路由改变活跃账号时清除。请勿手动设置。 | +| `mainAccountLastResort?` | `boolean` | `false` | 只要仍有已添加的 pool 账户可选,自动路由就继续使用这些账户,主 Codex 登录仅作为最后 fallback。显式手动选择会立即生效并跨代理重启保留;已有 thread affinity 及正常冷却/失败规则保持不变。 | | `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次临时上游失败后,让后续新 session failover 到其他合格 pool account。设为 `0` 可禁用失败切换。 | | `modelCacheTtlMs?` | `number` | `300000` | 每个 provider 的 `/models` 缓存新鲜度窗口(5 分钟)。 | diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index ef036e4298..b09eab3fb6 100644 --- a/readme/README.zh-CN.md +++ b/readme/README.zh-CN.md @@ -214,7 +214,7 @@ opencodex 保持两种独立行为: 当活跃账户越过阈值时,为新会话挑选使用量更低的合格账户。 - **主登录可保留到最后。** 将 `mainAccountLastResort` 设为 `true` 后,只要还有已添加的 pool 账户可选, 新建或重新绑定的工作就不会使用主登录。配额重平衡只在已添加账户之间进行;冷却与失败路径会先尝试其余 - 已添加账户,最后才使用主登录。显式手动选择账号仍会立即生效。 + 已添加账户,最后才使用主登录。显式手动选择账号会立即生效,并在代理重启后继续保留。 - **内置配额查询。** 仪表盘可一键刷新所有账户配额,请求日志用非 PII 的账户序号标记池流量。 - **失败即 fail-closed。** token 失败会标记需重新认证,而不是悄悄回退到另一个凭证;429 配额响应会让账户 进入冷却,并可将后续工作 failover 到另一个合格的池账户。 diff --git a/src/codex/account-lifecycle.ts b/src/codex/account-lifecycle.ts index 9186ea85b2..3e8973e3f0 100644 --- a/src/codex/account-lifecycle.ts +++ b/src/codex/account-lifecycle.ts @@ -47,6 +47,7 @@ export function deleteCodexAccount(runtimeConfig: OcxConfig, accountId: string): removeCodexAccountCredential(accountId); runtimeConfig.codexAccounts = (runtimeConfig.codexAccounts ?? []).filter(account => account.id !== accountId); if (runtimeConfig.activeCodexAccountId === accountId) runtimeConfig.activeCodexAccountId = undefined; + if (runtimeConfig.manualCodexAccountSelectionId === accountId) delete runtimeConfig.manualCodexAccountSelectionId; purgeCodexAccountRuntimeState(accountId); invalidateCodexWebSocketsForAccount(accountId); } diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index e00d1a42fe..e1228f33d3 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -607,7 +607,7 @@ export async function handleCodexAuthAPI( if (!exists) return jsonResponse({ error: "Account not found" }, 400); } runtimeConfig.activeCodexAccountId = body.accountId ?? undefined; - resetCodexRoutingForManualSelection(runtimeConfig, body.accountId ?? MAIN_CODEX_ACCOUNT_ID); + resetCodexRoutingForManualSelection(runtimeConfig, body.accountId); saveRuntimeConfig(config, runtimeConfig); return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true }); } diff --git a/src/codex/routing.ts b/src/codex/routing.ts index ad5b9ddaef..bdbf6bc1ec 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -24,7 +24,6 @@ export type CodexThreadResolution = | { status: "expired"; accountId: string }; const threadAccountMap = new Map(); -const manualAccountSelections = new WeakMap(); type CodexUpstreamHealth = { consecutiveFailures: number; /** Consecutive healthy terminals observed while recovering from escalation level 2+. */ @@ -306,15 +305,17 @@ function preservedCooldownFields(health: CodexUpstreamHealth | undefined): Parti return cooldownFields; } -/** Manual selection resets transient routing evidence without bypassing a real 429 cooldown. */ -export function resetCodexRoutingForManualSelection(config: OcxConfig, accountId: string): void { +/** Manual selection persists its origin and resets transient evidence without bypassing a real 429 cooldown. */ +export function resetCodexRoutingForManualSelection(config: OcxConfig, accountId: string | null): void { clearThreadAccountMap(); - manualAccountSelections.set(config, accountId); - const current = upstreamHealth.get(accountId); + if (accountId === null) delete config.manualCodexAccountSelectionId; + else config.manualCodexAccountSelectionId = accountId; + const selectedAccountId = accountId ?? MAIN_CODEX_ACCOUNT_ID; + const current = upstreamHealth.get(selectedAccountId); if (!current) return; const preserved = preservedCooldownFields(current); - if (Object.keys(preserved).length === 0) upstreamHealth.delete(accountId); - else upstreamHealth.set(accountId, { consecutiveFailures: 0, ...preserved }); + if (Object.keys(preserved).length === 0) upstreamHealth.delete(selectedAccountId); + else upstreamHealth.set(selectedAccountId, { consecutiveFailures: 0, ...preserved }); } export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null { @@ -505,14 +506,14 @@ function preferAddedPoolAccount(config: OcxConfig, active: string, now: number): if ( !config.mainAccountLastResort || active !== MAIN_CODEX_ACCOUNT_ID - || manualAccountSelections.get(config) === active + || config.manualCodexAccountSelectionId === active ) return active; return pickLowestUsageCodexAccount(config, MAIN_CODEX_ACCOUNT_ID, now) ?? active; } function setActiveCodexAccount(config: OcxConfig, accountId: string): void { if (config.activeCodexAccountId === accountId) return; - manualAccountSelections.delete(config); + delete config.manualCodexAccountSelectionId; config.activeCodexAccountId = accountId; saveConfigPreservingClaudeCode(config); } diff --git a/src/config.ts b/src/config.ts index 739be586fd..71d6109e12 100644 --- a/src/config.ts +++ b/src/config.ts @@ -489,6 +489,7 @@ const configSchema = z.object({ // Model ids excluded from the Grok Build managed block (dashboard switches). grokExcludedModels: z.array(z.string()).optional(), mainAccountLastResort: z.boolean().optional(), + manualCodexAccountSelectionId: z.string().min(1).optional(), // Invalid values degrade to undefined ("auto") instead of failing the whole // parse: a hand-edited typo must never trip the backup-and-defaults repair // path below and wipe providers/pool accounts. Warning emitted in loadConfig. diff --git a/src/types.ts b/src/types.ts index fa6ba1b5b5..77c8033d83 100644 --- a/src/types.ts +++ b/src/types.ts @@ -642,6 +642,8 @@ export interface OcxConfig { codexAccounts?: CodexAccount[]; /** Active pool account id for next session. undefined = main (passthrough as-is). */ activeCodexAccountId?: string; + /** Persisted provenance for an explicit account selection; cleared by automatic routing. */ + manualCodexAccountSelectionId?: string; /** Use the main Codex login only when no added pool account is selectable. Default false. */ mainAccountLastResort?: boolean; /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */ diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index f1610fd5c5..98aa46ff6f 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -23,7 +23,8 @@ Pool selection treats the main login and added accounts equally by default. The the main login from quota rebalancing away from an added account, and permits failure/cooldown failover to the main login only after no other added account is selectable. Existing thread affinity remains authoritative until its normal re-evaluation, expiry, or failure path runs. An -explicit manual account selection applies immediately until normal quota or failure routing changes it. +explicit manual account selection persists its account-id provenance across proxy restarts and applies +immediately until normal quota or failure routing changes it. ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index deb3d44cd7..8574662ff0 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -1101,6 +1101,7 @@ describe("codex-auth API", () => { expect(resp!.status).toBe(200); expect(await resp!.json()).toMatchObject({ activeCodexAccountId: "pool-next", appliesImmediately: true }); expect(config.activeCodexAccountId).toBe("pool-next"); + expect(config.manualCodexAccountSelectionId).toBe("pool-next"); }); test("PUT /api/codex-auth/accounts/alias changes display metadata only", async () => { @@ -1135,6 +1136,7 @@ describe("codex-auth API", () => { test("DELETE /api/codex-auth/accounts clears deleted active account from live runtime config", async () => { const config = makeConfig({ activeCodexAccountId: "pool-delete", + manualCodexAccountSelectionId: "pool-delete", codexAccounts: [{ id: "pool-delete", email: "pool-delete@example.test", isMain: false }], }); saveCodexAccountCredential("pool-delete", { @@ -1176,6 +1178,7 @@ describe("codex-auth API", () => { expect(resp!.status).toBe(200); expect(config.codexAccounts).toEqual([]); expect(config.activeCodexAccountId).toBeUndefined(); + expect(config.manualCodexAccountSelectionId).toBeUndefined(); expect(getCodexAccountCredential("pool-delete")).toBeNull(); expect(getAccountQuota("pool-delete")).toBeNull(); expect(isAccountNeedsReauth("pool-delete")).toBe(false); diff --git a/tests/codex-main-rotation.test.ts b/tests/codex-main-rotation.test.ts index 041bb80684..d86f180fec 100644 --- a/tests/codex-main-rotation.test.ts +++ b/tests/codex-main-rotation.test.ts @@ -26,12 +26,14 @@ import { } from "../src/codex/account-lifecycle"; import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "../src/codex/main-account"; import { saveCodexAccountCredential } from "../src/codex/account-store"; +import { loadConfig } from "../src/config"; import { clearAccountNeedsReauth, clearAccountQuota, clearMainAccountInfoCache, fetchMainAccountInfo, getAccountQuota, + handleCodexAuthAPI, isAccountNeedsReauth, markAccountNeedsReauth, primeCodexPoolQuotas, @@ -194,6 +196,46 @@ describe("main account rotation (Option A)", () => { expect(resolveCodexAccountForThread("after-manual-main-failure", config)).toBe("a"); }); + test("manual main selection survives save and reload until routing changes it", async () => { + const config = makeConfig({ + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + activeCodexAccountId: "a", + autoSwitchThreshold: 0, + mainAccountLastResort: true, + }); + updateAccountQuota("a", 20, 0); + updateAccountQuota(MAIN_CODEX_ACCOUNT_ID, 5, 0); + const request = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: MAIN_CODEX_ACCOUNT_ID }), + }); + + const response = await handleCodexAuthAPI(request, new URL(request.url), config); + expect(response?.status).toBe(200); + expect(config.manualCodexAccountSelectionId).toBe(MAIN_CODEX_ACCOUNT_ID); + + clearThreadAccountMap(); + clearCodexUpstreamHealth(); + const reloaded = loadConfig(); + expect(reloaded.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(reloaded.manualCodexAccountSelectionId).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(previewCodexAccountForRequest(null, reloaded)).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(resolveCodexAccountForThread("manual-main-reloaded", reloaded)).toBe(MAIN_CODEX_ACCOUNT_ID); + + recordCodexUpstreamOutcome(reloaded, MAIN_CODEX_ACCOUNT_ID, 429, { retryAfter: "60" }); + expect(reloaded.activeCodexAccountId).toBe("a"); + expect(reloaded.manualCodexAccountSelectionId).toBeUndefined(); + expect(loadConfig().manualCodexAccountSelectionId).toBeUndefined(); + }); + test("last-resort quota failover exhausts added accounts before main", () => { const config = makeConfig({ autoSwitchThreshold: 0, mainAccountLastResort: true }); const now = 1_800_000_000_000; diff --git a/tests/config.test.ts b/tests/config.test.ts index 9dd2fa9885..4a76a573b7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -167,6 +167,29 @@ describe("opencodex config defaults", () => { } }); + test("manual Codex account-selection provenance loads strings and rejects other values", () => { + const base = { + port: 10100, + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + }, + }, + defaultProvider: "openai", + }; + writeConfig({ ...base, manualCodexAccountSelectionId: "__main__" }); + expect(readConfigDiagnostics().config.manualCodexAccountSelectionId).toBe("__main__"); + + for (const invalid of [null, true, 1, ""]) { + writeConfig({ ...base, manualCodexAccountSelectionId: invalid }); + const diagnostics = readConfigDiagnostics(); + expect(diagnostics.source).toBe("fallback"); + expect(diagnostics.error).toContain("manualCodexAccountSelectionId"); + } + }); + test("multi-agent guidance is default-on and false is the only off state", () => { expect(getDefaultConfig().multiAgentGuidanceEnabled).toBe(true); expect(multiAgentGuidanceEnabled({})).toBe(true); From 7e0351d00708e57725644a25f2d950fe95abc754 Mon Sep 17 00:00:00 2001 From: Alvin0412 Date: Mon, 27 Jul 2026 03:10:12 +0800 Subject: [PATCH 3/3] test(codex): clarify main selection sentinel --- .../content/docs/reference/configuration.md | 4 +-- .../docs/zh-cn/reference/configuration.md | 4 +-- structure/08_openai-provider-tiers.md | 3 +- tests/codex-auth-api.test.ts | 30 +++++++++++++++++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration.md b/docs-site/src/content/docs/reference/configuration.md index 34c062f4a8..bbd2b51f1e 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -52,8 +52,8 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `codexShimAutoRestore?` | `boolean` | `true` | Restore a previously installed Codex shim when a completed external Codex update replaces it. Set `false`, or set `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0` for a process-level opt-out. | | `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility mode. opencodex backs up original Codex thread metadata, remaps old OpenAI interactive rows to `opencodex`, and temporarily promotes opencodex-created `exec` rows to an app-visible source. `ocx stop` / `ocx restore` restore backed-up OpenAI rows and eject remaining opencodex user threads to OpenAI so native Codex can resume them after the proxy is removed from `config.toml`. Set `false` to opt out. | | `codexAccounts?` | `CodexAccount[]` | `[]` | ChatGPT/Codex pool account metadata managed by the Codex Auth dashboard. Secrets live separately in `codex-accounts.json`. | -| `activeCodexAccountId?` | `string` | — | Manually selected Pool account. Selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | -| `manualCodexAccountSelectionId?` | `string` | — | Internal provenance marker for an explicit account selection. It survives proxy restarts and is cleared when automatic quota or failure routing changes the active account. Do not set by hand. | +| `activeCodexAccountId?` | `string` | — | Current Pool routing account ID; `__main__` denotes the main Codex login. For `PUT /api/codex-auth/active`, an ID is an explicit selection while `accountId: null` clears it into automatic no-pin mode. Manual selection clears existing thread affinity and applies to the next request; in-flight requests keep their captured account. | +| `manualCodexAccountSelectionId?` | `string` | — | Internal provenance marker for an explicit account selection, including `__main__`. It survives proxy restarts and is cleared when automatic quota or failure routing changes the active account. Do not set by hand. | | `mainAccountLastResort?` | `boolean` | `false` | Keep automatic routing on added Pool accounts while any are selectable, using the main Codex login only as the final fallback. Explicit manual selection applies immediately and survives proxy restarts; existing thread affinity and normal cooldown/failure rules still apply. | | `autoSwitchThreshold?` | `number` | `80` | Usage percent threshold for new-session auto-switching. The score uses the hottest known 5h, weekly, or 30d quota window. Set `0` to disable quota auto-switching. | | `upstreamFailoverThreshold?` | `number` | `3` | Consecutive transient upstream failures before future new sessions fail over to another eligible pool account. Set `0` to disable failure failover. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration.md b/docs-site/src/content/docs/zh-cn/reference/configuration.md index b4bbb2cff8..2b9368696e 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration.md @@ -49,8 +49,8 @@ no-replace 方式创建 `config.json.pre-openai-tiers-v2.bak`,并把已知旧 | `codexShimAutoRestore?` | `boolean` | `true` | 已完成的外部 Codex 更新替换此前安装的 shim 时自动恢复。若要关闭,请设为 `false`,或为进程设置 `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`。 | | `syncResumeHistory?` | `boolean` | `true` | 可逆的 Codex App 历史兼容模式。opencodex 会备份原始 Codex thread metadata,把旧 OpenAI interactive row 重映射到 `opencodex`,并暂时把 opencodex 创建的 `exec` row 提升成 App 可见 source。`ocx stop` / `ocx restore` 会恢复已备份的 OpenAI row,并把剩余 opencodex user thread 转回 OpenAI,使原生 Codex 在从 `config.toml` 移除代理后仍能继续这些 thread。设为 `false` 可退出该模式。 | | `codexAccounts?` | `CodexAccount[]` | `[]` | Codex Auth 仪表盘管理的 ChatGPT/Codex pool account metadata。secret 单独存放在 `codex-accounts.json`。 | -| `activeCodexAccountId?` | `string` | — | 手动选择的 pool account。选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | -| `manualCodexAccountSelectionId?` | `string` | — | 显式手动选择的内部来源标记。它会跨代理重启保留,并在自动配额或失败路由改变活跃账号时清除。请勿手动设置。 | +| `activeCodexAccountId?` | `string` | — | 当前 Pool 路由账号 ID;`__main__` 表示主 Codex 登录。对 `PUT /api/codex-auth/active` 而言,传入 ID 表示显式选择,`accountId: null` 则清除选择并进入不固定账号的自动模式。手动选择时清除已有 thread affinity,并从下一次请求开始生效;进行中的请求保留原账号。 | +| `manualCodexAccountSelectionId?` | `string` | — | 显式手动选择的内部来源标记,包括 `__main__`。它会跨代理重启保留,并在自动配额或失败路由改变活跃账号时清除。请勿手动设置。 | | `mainAccountLastResort?` | `boolean` | `false` | 只要仍有已添加的 pool 账户可选,自动路由就继续使用这些账户,主 Codex 登录仅作为最后 fallback。显式手动选择会立即生效并跨代理重启保留;已有 thread affinity 及正常冷却/失败规则保持不变。 | | `autoSwitchThreshold?` | `number` | `80` | 新 session 自动切换的 usage 百分比 threshold。分数取已知 5 小时、周或 30 天 quota window 中最高的一项。设为 `0` 可禁用 quota 自动切换。 | | `upstreamFailoverThreshold?` | `number` | `3` | 连续发生多少次临时上游失败后,让后续新 session failover 到其他合格 pool account。设为 `0` 可禁用失败切换。 | diff --git a/structure/08_openai-provider-tiers.md b/structure/08_openai-provider-tiers.md index 98aa46ff6f..d709322bf0 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -24,7 +24,8 @@ the main login from quota rebalancing away from an added account, and permits fa failover to the main login only after no other added account is selectable. Existing thread affinity remains authoritative until its normal re-evaluation, expiry, or failure path runs. An explicit manual account selection persists its account-id provenance across proxy restarts and applies -immediately until normal quota or failure routing changes it. +immediately until normal quota or failure routing changes it. The active-account API uses `__main__` +for an explicit main-login selection; `null` means automatic selection with no pin. ```text gpt-5.6-sol # openai; Pool or Direct follows the provider option diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 8574662ff0..3d6d7c9f46 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -1104,6 +1104,36 @@ describe("codex-auth API", () => { expect(config.manualCodexAccountSelectionId).toBe("pool-next"); }); + test("PUT /api/codex-auth/active distinguishes explicit main from automatic selection", async () => { + const config = makeConfig({ + activeCodexAccountId: "pool-next", + manualCodexAccountSelectionId: "pool-next", + }); + const selectMain = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: MAIN_CODEX_ACCOUNT_ID }), + }); + const mainResponse = await handleCodexAuthAPI(selectMain, new URL(selectMain.url), config); + + expect(mainResponse!.status).toBe(200); + expect(await mainResponse!.json()).toMatchObject({ activeCodexAccountId: MAIN_CODEX_ACCOUNT_ID }); + expect(config.activeCodexAccountId).toBe(MAIN_CODEX_ACCOUNT_ID); + expect(config.manualCodexAccountSelectionId).toBe(MAIN_CODEX_ACCOUNT_ID); + + const selectAutomatically = new Request("http://localhost/api/codex-auth/active", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId: null }), + }); + const automaticResponse = await handleCodexAuthAPI(selectAutomatically, new URL(selectAutomatically.url), config); + + expect(automaticResponse!.status).toBe(200); + expect(await automaticResponse!.json()).toMatchObject({ activeCodexAccountId: null }); + expect(config.activeCodexAccountId).toBeUndefined(); + expect(config.manualCodexAccountSelectionId).toBeUndefined(); + }); + test("PUT /api/codex-auth/accounts/alias changes display metadata only", async () => { const config = makeConfig({ codexAccounts: [{ id: "work", email: "work@example.test", plan: "plus", isMain: false }],