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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ 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 hard threshold that also overrides thread affinity, see
[strict Codex pool quota admission](docs/codex-strict-quota.md). It reuses the
existing selectors, including fill-first, and verifies quota recovery before
re-enabling an account.

### For agents

```bash
Expand Down
67 changes: 67 additions & 0 deletions docs/codex-strict-quota.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Strict Codex pool quota admission

Strict quota admission is opt-in. It uses the existing account selector, threshold,
manual pin, credential store, and quota endpoints. 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

- A stored pool account is eligible only when its observed shared quota windows
are below the configured threshold. The effective ceiling is 99%, even if the
configured threshold is 100%. Setting the threshold to zero disables this
admission policy, consistent with the existing auto-switch control.
- A manual selection and thread affinity cannot override the quota gate. With
strict `fill-first`, an eligible selected account stays active until it is
unavailable, including accounts selected automatically. Higher-priority accounts
that recover do not preempt it. When another account is needed, higher selection
order numbers are preferred. This reuses the existing priority-tier selector
without creating a persistent manual pin for an automatic selection.
- A below-threshold snapshot is usable for five minutes. Unknown or stale quota
triggers a coalesced usage read before selection. Failed reads also receive a
five-minute backoff; they do not turn unknown quota into 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. A predicted
reset can schedule an earlier verification. Manual usage refreshes wake pending
requests. Unexpected upstream resets are discovered by the next due usage read.
- This feature never redeems reset credits. Existing separately configured reset
automation is independent and must remain disabled when manual redemption is
desired.

## 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)复用现有填满优先等策略,到达阈值后不再因亲和使用该账户,真实额度恢复后重新参与选择;带有配额刷新和非 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
28 changes: 27 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 @@ -2157,21 +2177,27 @@ 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);
notifyCodexQuotaChanges();
return jsonResponse({ ok: true });
}

Expand Down
Loading
Loading