Skip to content
Draft
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,13 @@ account exclusion, affinity expiry, or 401/403 and 429 recovery can rebind them.
selection order when one of them — usually your Codex Desktop login — should only be reached for
once the others are drained.

For an opt-in soft threshold that can move a request away from a high-usage account
while preserving any usable remainder, see
[strict Codex pool quota admission](docs/codex-strict-quota.md). It reuses the
existing selectors, including fill-first; only a confirmed 100% window is blocked,
and recovery is verified from fresh quota metadata before an exhausted account is
re-enabled.

### For agents

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,15 @@ OAuth 및 API 키 제공자에는 제공자의 할당량 보고 엔드포인트
{ provider, autoSwitchThreshold: number, enabled: boolean }
```

`codexAccountStrictQuota: true`이면 이 임계값은 선제 전환을 위한 소프트 선호입니다. 적격한
계정 중 임계값 미만인 계정을 먼저 고르지만, 사용할 수 있는 계정에 100% 미만의 잔여량이
있으면 계속 사용할 수 있습니다. 확인된 100% 창만 하드 차단되며 모든 적격 계정이 소진된
경우에만 요청이 대기합니다. 읽기는 WHAM metadata를 병합해 짧게 캐시하고, 실패한 읽기는
5분 backoff를 적용하며, reset 시간은 복구를 가정하지 않고 다음 읽기만 실행합니다. 독립적인
`codexMainAccountHardLock` 스위치는 별도의 main 계정 제한으로 유지됩니다. 자세한 내용은
[strict Codex pool quota guide](https://github.com/lidge-jun/opencodex/blob/main/docs/codex-strict-quota.md)를
참조하세요.

### `ocx account priority <provider> <account-id|main> [<-100..100|first|earlier|normal|later|last|reset>] [--json]`

Codex pool 계정 하나의 선택 순서를 읽거나 설정합니다. **값이 클수록 먼저** 쓰이고 기본값은 `0`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,14 @@ exit 1. `--json` returns:
{ provider, autoSwitchThreshold: number, enabled: boolean }
```

With `codexAccountStrictQuota: true`, this threshold is a soft switching preference: eligible
accounts below it are preferred, but a usable account may continue with any remaining quota below
100%. Only a confirmed 100% window is hard-blocked; requests wait only when every usable account
is exhausted. Reads use merged WHAM metadata with a short cache, failed reads back off for five
minutes, and a reset time triggers a read without implying recovery. The independent
`codexMainAccountHardLock` switch remains a separate main-account restriction. See the
[strict Codex pool quota guide](https://github.com/lidge-jun/opencodex/blob/main/docs/codex-strict-quota.md).

### `ocx account priority <provider> <account-id|main> [<-100..100|first|earlier|normal|later|last|reset>] [--json]`

Reads or sets one Codex pool account's selection order: **higher is used earlier**, the default is
Expand Down
74 changes: 74 additions & 0 deletions docs/codex-strict-quota.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Strict Codex pool quota admission

Strict quota admission is opt-in. It uses the existing account selector, threshold,
manual pin, credential store, and WHAM quota metadata endpoint. It does not introduce
another account selection strategy.

To keep using a manually selected account until its threshold is reached, select
`fill-first` and enable strict admission in the configuration:

```json
{
"accountPoolStrategy": "fill-first",
"autoSwitchThreshold": 95,
"codexAccountStrictQuota": true
}
```

The existing `PUT /api/codex-auth/auto-switch` management endpoint also accepts
`{"threshold":95,"strictQuota":true}`. Omit `strictQuota` to keep its current value.
`GET /api/codex-auth/active` reports `codexAccountStrictQuota`. Existing management
authentication requirements apply. There is no new GUI control.

## Selection and recovery

- `autoSwitchThreshold` is a soft preference. When strict quota is enabled, selection
first filters for actually usable accounts (credentials, pause, reauthentication,
cooldown, model, and hard quota state), then prefers an account below the threshold.
If none is below it, the current usable account may continue with any remaining
quota below 100%. Only a confirmed 100% window is a hard quota block. When every
usable account is truly exhausted, the request waits for new evidence.
- A manual selection and thread affinity cannot make a paused, cooling, reauth-needed,
model-ineligible, or 100%-blocked account usable. With strict `fill-first`, an
eligible selected account stays active when no below-threshold replacement exists.
Higher-priority accounts are preferred when a switch is possible; this reuses the
existing priority-tier selector without creating a persistent manual pin for an
automatic selection. The independent `codexMainAccountHardLock` protection switch
remains separate and may still restrict the main account; strict quota does not
promise to override it.
- Quota reads use the WHAM metadata endpoint. Selection merges concurrent reads and
uses a short 10-second cache; a failed read earns a five-minute backoff. A reset
timestamp only makes the next metadata read due and never implies that quota has
recovered. Unknown or stale quota is never treated as zero usage.
- A measured block survives stale cache data, token refresh, and predicted reset
deadlines. A new valid quota reading must establish recovery. Partial or
credits-only responses cannot clear another window's known block.
- Only pending requests own recovery timers. Usage reads are shared and bounded;
with no pending request this feature performs no periodic work. Manual usage
refreshes wake pending requests. This feature sends no warmup model requests and
never redeems reset credits.
- Selecting main or enabling strict quota while main is active reads its identity-bound
usage in the management operation. If main usage is missing after startup or has
gone stale, a real waiting request requests metadata through a separate owned main
profile claim. Caller-owned authentication still does not read local credentials;
a failed metadata read keeps the request waiting with backoff.

## Request boundaries

A recognized pre-stream quota refusal may try each available account once. When
all candidate accounts are quota-blocked or unknown, a Responses request waits
for new evidence. Streaming requests emit `response.heartbeat` while waiting and
then forward the real upstream stream. Cancellation and service drain terminate
the wait and release its resources. Waiting does not synthesize a completed
response.

Ordinary server errors, an uncertain WebSocket execution outcome, and a stream
that already produced output do not authorize this cross-account replay. The
existing stored-account 401 replay budget remains bounded across waiting cycles.
Client or network disconnects still terminate requests; this is not durable job
storage and does not promise recovery after the proxy process exits.

Explicit Direct credentials and independent Spark/Reserve quota authorization
retain their own policies. An explicit account namespace does not silently switch
to another account. Authentication failures and operator-paused accounts remain
unavailable until their actual cause is repaired.
2 changes: 1 addition & 1 deletion readme/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ npm 警告里给出的缩写命令缺少包名,会把当前目录重新安装

- **在 Codex 中使用任意 LLM。** 5 种协议 adapter 覆盖 Anthropic Messages、Google Gemini、Azure、OpenAI Responses 直通,以及所有 OpenAI 兼容 Chat Completions 端点 —— 即开箱即用的 **40+ provider**。
- **在 Claude 中也能使用任意 LLM。** `ocx claude` 可通过代理启动 Claude Code。Claude 仪表盘还提供独立的 Desktop 配置,可管理 Opus、Fable、Sonnet、Haiku 四个系列,并支持拖放、键盘操作和 JSON 导入/导出。
- **安全地池化 ChatGPT 账户。** 现有 Codex 线程保持在一个账户上,而新会话可以从池中自动挑选使用量更低的账户,并带有配额刷新和非 PII 请求标签。
- **安全地池化 ChatGPT 账户。** 现有 Codex 线程通常保持账户亲和,新会话按配置策略选取账户。可选的[严格额度门禁](../docs/codex-strict-quota.md)复用现有填满优先等策略,把阈值作为软切换偏好;只有确认达到 100% 才会硬拦,真实额度恢复后重新参与选择;带有配额刷新和非 PII 请求标签。
- **登录一次,免填 API key。** xAI、Anthropic、Kimi 支持 OAuth,可用现有账户认证,token 自动刷新。也可以转发 `codex login`、粘贴 API key,或使用 `${ENV_VAR}` 引用 —— 随你选择。
- **Codex 在哪里能用,它就在哪里能用。** 自动注入 Codex CLI、TUI、App 和 SDK。路由模型像原生模型一样出现在 Codex 的模型选择器里。
- **委派给合适的模型。** 在仪表盘或 config 中把最多 5 个路由/原生模型放进 Codex 的 subagent 选择器 —— 复杂任务交给 reasoning 模型,快速任务交给便宜模型。在 v2 多智能体表面(GPT-5.6 Sol/Terra)上,代理会注入精简的委派指引:首选子智能体模型与 effort(`injectionModel` / `injectionEffort`)、featured 模型清单及各自支持的 effort 阶梯,以及让跨模型 `spawn_agent` 覆盖得以应用的 `fork_turns` 规则。已知限制:原生父代理 spawn 路由子代理时,任务正文可能以后端加密形式到达而丢失([#92](https://github.com/lidge-jun/opencodex/issues/92))—— 需要可靠的跨 provider 委派请使用 v1 表面。想自定义文案,可在 `injectionPrompt` 中使用 `{{model}}` / `{{effort}}` / `{{roster}}` 占位符。
Expand Down
3 changes: 3 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,8 @@
"codex-restore-app-rewrite.test.ts": "codex-integration",
"codex-retained-root-serialization.test.ts": "codex-integration",
"codex-routing.test.ts": "codex-integration",
"codex-strict-quota.test.ts": "codex-integration",
"codex-strict-quota-refresh.test.ts": "codex-integration",
"codex-runtime.test.ts": "codex-integration",
"codex-service-manager-probe-hardening.test.ts": "codex-integration",
"codex-service-manager-probe.test.ts": "codex-integration",
Expand Down Expand Up @@ -1083,6 +1085,7 @@
"server-403-permission-e2e.test.ts": "server",
"server-agent-task-recovery-replay.test.ts": "server",
"server-auth.test.ts": "server",
"server-strict-quota-wait.test.ts": "server",
"server-background-lifecycle.test.ts": "server",
"server-clickjacking-headers.test.ts": "server",
"server-combo-failover-e2e.test.ts": "server",
Expand Down
5 changes: 5 additions & 0 deletions src/codex/account-usability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,24 @@ import { isNativeMainTrafficBlocked } from "./native-profile-startup";
import { isMainAccountHardLocked } from "./main-account-hard-lock";

export interface CodexAccountUsabilityOptions {
/** Live admission policy when the routing config is a request-specific replay snapshot. */
strictQuotaPolicy?: Readonly<Pick<OcxConfig, "codexAccountStrictQuota" | "autoSwitchThreshold">>;
/** Route using cached runtime state only; the caller must reject selected main before auth. */
nativeMainSelectionOnly?: boolean;
/** Test seam for proving whether routing attempted a physical native-token read. */
isMainAccountTokenLive?: typeof isMainAccountTokenLive;
/** Confirmed account ids for an account-gated model; omitted for ordinary native models. */
modelEligibleAccountIds?: ReadonlySet<string>;
/** Request-local retry exclusions are independent of the model's entitlement roster. */
excludedAccountIds?: ReadonlySet<string>;
}

export function isCodexAccountUsable(
config: OcxConfig,
accountId: string,
options: CodexAccountUsabilityOptions = {},
): boolean {
if (options.excludedAccountIds?.has(accountId)) return false;
if (options.modelEligibleAccountIds && !options.modelEligibleAccountIds.has(accountId)) return false;
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
if (isMainAccountHardLocked(config)) return false;
Expand Down
41 changes: 40 additions & 1 deletion src/codex/auth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
withConfigMutationLockSync,
} from "../config";
import { codexAccountLogLabel, withCodexAccountLogLabel } from "./account-label";
import { notifyCodexQuotaChanges } from "./quota-events";
import {
getCodexAccountCredential,
getValidCodexToken,
Expand Down Expand Up @@ -1463,6 +1464,25 @@ async function fetchPoolAccountQuota(
}
}

/** Explicitly read-only usage refresh for request-owned strict-quota recovery. */
export async function refreshStrictCodexPoolQuotaSnapshots(
config: OcxConfig, accountIds: readonly string[],
policy: Pick<OcxConfig, "codexAccountStrictQuota"> = config,
): Promise<void> {
if (policy.codexAccountStrictQuota !== true) return;
await mapWithConcurrency([...accountIds], POOL_QUOTA_REFRESH_CONCURRENCY, async id => {
if (isCodexAccountPaused(config, id)) return;
if (id === MAIN_CODEX_ACCOUNT_ID) {
// A background/request check cannot clear a reauth quarantine or redeem credits.
await fetchMainAccountInfoAttempt(true, 1, undefined, false, false);
return;
}
const account = configuredPoolAccount(config, id);
if (!account || isAccountNeedsReauth(id)) return;
await fetchPoolAccountQuota(id, true, account.plan);
});
}

let primeInFlight: Promise<void> | null = null;
/**
* Last prime attempt per pool account. A failed WHAM lookup stores no quota, so
Expand Down Expand Up @@ -2142,6 +2162,14 @@ export async function handleCodexAuthAPI(
else setCodexAccountPin(runtimeConfig, targetAccountId);
resetCodexRoutingForManualSelection(targetAccountId);
saveRuntimeConfig(config, runtimeConfig);
// Management owns the physical-main claim. Rebuild identity-bound usage here;
// caller-owned requests cannot read auth.json to recover an unknown main snapshot.
if (targetAccountId === MAIN_CODEX_ACCOUNT_ID && runtimeConfig.codexAccountStrictQuota === true
&& (runtimeConfig.autoSwitchThreshold ?? 80) > 0) {
await fetchMainAccountInfoAttempt(true, 1, undefined, false, false);
}
// A pending strict-quota request must reconsider an operator-selected account now.
notifyCodexQuotaChanges();
return jsonResponse({ ok: true, activeCodexAccountId: body.accountId, appliesImmediately: true });
}

Expand All @@ -2157,21 +2185,32 @@ export async function handleCodexAuthAPI(
// lets a surface mark the account the operator actually chose.
pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null,
autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
codexAccountStrictQuota: runtimeConfig.codexAccountStrictQuota === true,
upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
});
}

if (url.pathname === "/api/codex-auth/auto-switch" && req.method === "PUT") {
let body: { threshold: number };
let body: { threshold: number; strictQuota?: unknown };
try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); }
if (typeof body.threshold !== "number" || !Number.isInteger(body.threshold) || body.threshold < 0 || body.threshold > 100) {
return jsonResponse({ error: "Threshold must be an integer 0-100" }, 400);
}
if (body.strictQuota !== undefined && typeof body.strictQuota !== "boolean") {
return jsonResponse({ error: "strictQuota must be a boolean" }, 400);
}
const runtimeConfig = getRuntimeConfig(config);
runtimeConfig.autoSwitchThreshold = body.threshold;
if (typeof body.strictQuota === "boolean") runtimeConfig.codexAccountStrictQuota = body.strictQuota;
saveRuntimeConfig(config, runtimeConfig);
if (runtimeConfig.codexAccountStrictQuota === true && body.threshold > 0
&& (getEffectiveActiveCodexAccountId(runtimeConfig) ?? MAIN_CODEX_ACCOUNT_ID) === MAIN_CODEX_ACCOUNT_ID
&& !isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID)) {
await fetchMainAccountInfoAttempt(true, 1, undefined, false, false);
}
notifyCodexQuotaChanges();
return jsonResponse({ ok: true });
}

Expand Down
Loading
Loading