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 docs-site/src/content/docs/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ plus `openai-apikey/<model>` for the configured API key. Pool includes main plus
Direct uses only the caller/main bearer. The routes do not fall back to one another. Shipped v1
configs migrate to marker 2 and preserve `config.json.pre-openai-tiers-v2.bak` for manual restore.

Within Pool mode, a request carrying a validated native Codex login can use that login when the
selected stored account is cooling down and no eligible stored alternative or recovery probe is
available. This also covers a new request blocked before sending, following the same caller
validation used after an upstream rejection. Existing model-permission and main-account policy
checks still apply. The fallback preserves the stored account's cooldown and does not persist the
caller credential as the Pool selection. An exact account binding remains bound to that account.

## Config injection

`ocx init`, `ocx start`, and `ocx sync` call the injector. On the default loopback bind, it keeps
Expand Down
6 changes: 6 additions & 0 deletions docs-site/src/content/docs/ko/guides/codex-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ opencodex는 Codex가 읽는 두 가지, 즉 설정(`$CODEX_HOME/config.toml`,

프록시는 bare `openai` Codex 로그인 경로 하나와 Pool(기본) 및 Direct 계정 모드, 그리고 설정된 API 키용 `openai-apikey/<model>`을 제공합니다. Pool은 메인 계정과 추가된 계정을 포함하고, Direct는 호출자/메인 bearer만 사용합니다. 경로들은 서로 fallback하지 않습니다. shipped v1 config는 marker 2로 이관되며, 수동 복원을 위해 `config.json.pre-openai-tiers-v2.bak`를 보존합니다.

Pool 모드에서는 선택된 저장 계정이 쿨다운 중이고 사용 가능한 다른 저장 계정이나 복구 probe가
없을 때, 요청에 포함된 검증된 native Codex 로그인을 사용할 수 있습니다. 상류 거절 후 재시도와
같은 호출자 검증을 적용하므로, 전송 전에 막힌 새 요청도 이 경로를 사용할 수 있습니다. 기존 모델
권한과 main 계정 정책 검사는 유지됩니다. 이 fallback은 저장 계정의 쿨다운을 해제하거나 호출자
인증을 Pool 선택으로 저장하지 않습니다. 특정 계정에 정확히 고정된 요청은 그 계정에 계속 묶입니다.

## 설정 주입

`ocx init`, `ocx start`, `ocx sync`는 모두 인젝터를 호출합니다. 기본 loopback 바인드에서는 Codex의 빌트인 `openai` 프로바이더 id를 그대로 유지한 채, 그 프로바이더가 opencodex를 바라보게 합니다.
Expand Down
7 changes: 7 additions & 0 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,13 @@ export async function resolveCodexAuthContext(
? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined
: tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
if (!probeLeaseId) {
// The selector can retain the configured Pool account when no stored
// alternate is eligible. A validated caller may still serve this request,
// just as it can after an upstream rejection, without changing Pool state.
if (requestScopedMainCredential && fixedAccountId === undefined
&& options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID) {
return await resolveCallerOwnedMainContext();
}
throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
}
}
Expand Down
39 changes: 39 additions & 0 deletions tests/codex-integration/codex-auth-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
CODEX_QUOTA_PROBE_INTERVAL_MS,
clearCodexUpstreamHealth,
clearThreadAccountMap,
getCodexQuotaHealthSnapshot,
recordCodexUpstreamOutcome,
resetCodexRoutingForManualSelection,
} from "../../src/codex/routing";
Expand Down Expand Up @@ -1262,6 +1263,44 @@ describe("Codex auth context", () => {
expect(directEntitlementChecks).toBe(1);
});

test("a fresh request can reuse caller main after the selected Pool account enters cooldown", async () => {
const cfg = { ...config(), autoSwitchThreshold: 0 };
const now = 1_800_000_000_000;
const originalNow = Date.now;
const inbound = new Headers({
authorization: "Bearer caller-keyring-token",
"chatgpt-account-id": "caller-keyring-account",
});
saveCodexAccountCredential("pool-a", {
accessToken: "pool_token", refreshToken: "pool_refresh",
expiresAt: now + 24 * 60 * 60_000, chatgptAccountId: "pool_acc",
});
try {
Date.now = () => now;
recordCodexUpstreamOutcome(cfg, "pool-a", 429, {
now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true,
});
const cooldown = getCodexQuotaHealthSnapshot("pool-a", "shared");
expect(cooldown).not.toBeNull();
Date.now = () => now + 1_000;
const options = { requestScopedMainCredential: true, modelId: "gpt-5.6-terra" };
await expect(resolveCodexAuthContext(inbound, cfg, "pool", {
...options, excludeAccountId: "pool-a",
})).resolves.toMatchObject({ kind: "main", accountId: null });

const context = await resolveCodexAuthContext(inbound, cfg, "pool", options);
expect(context).toMatchObject({ kind: "main", accountId: null });
const forwarded = headersForCodexAuthContext(inbound, context);
expect(forwarded.get("authorization")).toBe("Bearer caller-keyring-token");
expect(forwarded.get("chatgpt-account-id")).toBe("caller-keyring-account");
expect(cfg.activeCodexAccountId).toBe("pool-a");
expect(cfg.activeCodexAccountPinned).toBeUndefined();
expect(getCodexQuotaHealthSnapshot("pool-a", "shared")).toEqual(cooldown);
} finally {
Date.now = originalNow;
}
});

test("selects pool auth independently of the routed provider", async () => {
saveCodexAccountCredential("pool-a", {
accessToken: "pool_token",
Expand Down
30 changes: 29 additions & 1 deletion tests/codex-integration/main-account-hard-lock-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
observeMainQuotaIdentity,
} from "../../src/codex/main-account-cache";
import { clearAccountQuota, getMainPolicyQuota, setAccountQuotaFromParsed } from "../../src/codex/quota";
import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth } from "../../src/codex/routing";
import { clearCodexUpstreamHealth, clearThreadAccountMap, getCodexUpstreamHealth, getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome } from "../../src/codex/routing";
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../../src/providers/openai-sidecar";
import { mapCodexAuthContextErrorToResponse } from "../../src/server/responses/codex-auth-error";
import { handleResponses } from "../../src/server/responses/core";
Expand Down Expand Up @@ -185,6 +185,34 @@ describe("main quota policy at native admission", () => {
})).rejects.toBeInstanceOf(CodexMainAccountHardLockError);
});

for (const percent of [98.99, 99]) {
test(`Pool cooldown caller fallback keeps the main ${percent}% policy boundary`, async () => {
const cfg = config();
addAlternative(cfg);
cfg.activeCodexAccountId = "hard-lock-pool";
observeMainQuotaCredential(bearer(), accountId);
quota(percent);
const now = Date.now();
recordCodexUpstreamOutcome(cfg, "hard-lock-pool", 429, {
now, modelId: "gpt-5.6-terra", resetAt: now + 600_000, fixedAccount: true,
});
const cooldown = getCodexQuotaHealthSnapshot("hard-lock-pool", "shared");
expect(cooldown).not.toBeNull();
spyOn(Date, "now").mockReturnValue(now + 1_000);
forbidPhysicalReads();
const context = resolveCodexAuthContext(caller(), cfg, "pool", {
requestScopedMainCredential: true, modelId: "gpt-5.6-terra",
});
if (percent < 99) {
await expect(context).resolves.toMatchObject({ kind: "main", accountId: null });
} else {
await expect(context).rejects.toBeInstanceOf(CodexMainAccountHardLockError);
}
expect(cfg.activeCodexAccountId).toBe("hard-lock-pool");
expect(getCodexQuotaHealthSnapshot("hard-lock-pool", "shared")).toEqual(cooldown);
});
}

test("unmatched, spoofed-claim, and conflicting-workspace callers do not inherit main policy", async () => {
observeMainQuotaCredential(bearer(), accountId);
quota(99);
Expand Down
Loading