Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs-site/src/content/docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 分钟)。 |
Expand Down
3 changes: 3 additions & 0 deletions readme/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ opencodex 保持两种独立行为:
会继续使用同一账户。
- **新会话可自动路由。** 启用自动切换后,opencodex 比较 5 小时、每周、30 天使用量中最热的配额窗口,
当活跃账户越过阈值时,为新会话挑选使用量更低的合格账户。
- **主登录可保留到最后。** 将 `mainAccountLastResort` 设为 `true` 后,只要还有已添加的 pool 账户可选,
新建或重新绑定的工作就不会使用主登录。配额重平衡只在已添加账户之间进行;冷却与失败路径会先尝试其余
已添加账户,最后才使用主登录。显式手动选择账号会立即生效,并在代理重启后继续保留。
- **内置配额查询。** 仪表盘可一键刷新所有账户配额,请求日志用非 PII 的账户序号标记池流量。
- **失败即 fail-closed。** token 失败会标记需重新认证,而不是悄悄回退到另一个凭证;429 配额响应会让账户
进入冷却,并可将后续工作 failover 到另一个合格的池账户。
Expand Down
1 change: 1 addition & 0 deletions src/codex/account-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
2 changes: 1 addition & 1 deletion src/codex/auth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
41 changes: 34 additions & 7 deletions src/codex/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
9 changes: 9 additions & 0 deletions structure/08_openai-provider-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions tests/codex-auth-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading