diff --git a/README.md b/README.md index 66808ae6b4..a4a1925ce9 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 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 122253b742..bbd2b51f1e 100644 --- a/docs-site/src/content/docs/reference/configuration.md +++ b/docs-site/src/content/docs/reference/configuration.md @@ -52,7 +52,9 @@ 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. | +| `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. | | `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..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,7 +49,9 @@ 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,并从下一次请求开始生效;进行中的请求保留原账号。 | +| `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` 可禁用失败切换。 | | `modelCacheTtlMs?` | `number` | `300000` | 每个 provider 的 `/models` 缓存新鲜度窗口(5 分钟)。 | diff --git a/readme/README.zh-CN.md b/readme/README.zh-CN.md index b4ef7a8de1..b09eab3fb6 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/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 5d1fba5da3..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(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 645f4c59b7..bdbf6bc1ec 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -305,14 +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(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(); - 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 { @@ -449,7 +452,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 +464,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 +479,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 +502,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 + || 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; + delete config.manualCodexAccountSelectionId; config.activeCodexAccountId = accountId; saveConfigPreservingClaudeCode(config); } @@ -587,6 +608,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 +699,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..71d6109e12 100644 --- a/src/config.ts +++ b/src/config.ts @@ -488,6 +488,8 @@ 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(), + 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 76fdd5b3ad..77c8033d83 100644 --- a/src/types.ts +++ b/src/types.ts @@ -642,6 +642,10 @@ 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. */ 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..d709322bf0 100644 --- a/structure/08_openai-provider-tiers.md +++ b/structure/08_openai-provider-tiers.md @@ -18,6 +18,15 @@ 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 persists its account-id provenance across proxy restarts and applies +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 openai-apikey/gpt-5.6-sol # OpenAI API key diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index deb3d44cd7..3d6d7c9f46 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -1101,6 +1101,37 @@ 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/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 () => { @@ -1135,6 +1166,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 +1208,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 1c79d5b244..d86f180fec 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 { @@ -23,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, @@ -137,6 +142,151 @@ 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("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; + 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..4a76a573b7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -144,6 +144,52 @@ 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("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);