Skip to content
Merged
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
63 changes: 60 additions & 3 deletions src/codex/auth-context.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { createHmac, randomBytes } from "node:crypto";
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import {
CodexCredentialGenerationConflictError,
CodexCredentialRefreshLockTimeoutError,
CodexCredentialRefreshBusyError,
CodexCredentialRefreshStaleError,
getCodexAccountCredential,
getValidCodexToken,
isCodexAccountGenerationLive,
} from "./account-store";
Expand Down Expand Up @@ -49,7 +50,7 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
import { FORWARD_HEADERS } from "../adapters/openai-responses";
import { captureConfigGeneration } from "../lib/state-store-sweeper";
import { retainedUtf8Bytes } from "../lib/admission";
import { extractAccountId } from "../oauth/chatgpt";
import { extractAccountId, extractEmail } from "../oauth/chatgpt";
import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock";
import {
captureMainAccountIdentityGeneration,
Expand All @@ -62,7 +63,7 @@ import {
} from "./main-account-cache";
import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported, isCodexReserveRequestEligible } from "./loopback-target";
import type { DataPlaneAdmission } from "../server/auth-cors";
import { getMainReserveAuthorization, isMainReserveAuthorizationLive, type MainReserveAuthorization } from "./reserve-availability";
import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability";
import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";

const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
Expand Down Expand Up @@ -455,6 +456,54 @@ function callerMatchesObservedMain(headers: Headers): boolean {
return matchesMainQuotaCredential(bearer, effectiveAccountId);
}

/** Constant-time digest comparison so bearer bytes never drive branch timing. */
function sameCredentialMaterial(a: string, b: string): boolean {
return timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());
}

/**
* The early-cooldown caller-main fallback must not resurrect the subscription that is cooling
* down. Fail closed on ambiguity: an unreadable caller identity cannot be distinguished from the
* cooled account. A distinct workspace account id is always safe; an exact materialized
* bearer + account tuple marks the same subscription. Beyond that, the stable native user id is
* the strongest available evidence: it survives an email change and a token rotation, and it
* separates members who share one workspace account id even when neither credential carries an
* email. Email remains the fallback when no comparable user id exists on both sides. Coexisting
* personal/business registrations with the same email and account id over-deny during the
* cooldown — the safe direction.
*/
function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean {
const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
if (!bearer) return true;
const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
if (callerAccountId === undefined) return true;
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
// No physical-main read to identify the caller: the observed-main equality tag suffices.
return callerMatchesObservedMain(headers);
}
const stored = getCodexAccountCredential(accountId);
const entry = config.codexAccounts?.find(account => account.id === accountId);
const cooledAccountId = stored?.chatgptAccountId || entry?.chatgptAccountId;
if (!cooledAccountId) return true;
if (cooledAccountId !== callerAccountId) return false;
if (stored?.accessToken && sameCredentialMaterial(bearer, stored.accessToken)) return true;
// Same namespace on both sides, never `sub`: this is the ChatGPT per-user identity the reserve
// path already trusts. A credential whose own two encodings of it disagree cannot identify
// anyone, so it fails closed even when the disagreement is on the stored side.
const callerUser = nativeUserIdClaims(bearer);
const cooledUser = stored?.accessToken
? nativeUserIdClaims(stored.accessToken)
: { userId: undefined, conflict: false };
if (callerUser.conflict || cooledUser.conflict) return true;
if (callerUser.userId !== undefined && cooledUser.userId !== undefined) {
return callerUser.userId === cooledUser.userId;
}
const callerEmail = extractEmail(undefined, bearer)?.trim().toLowerCase() || undefined;
const cooledEmail = entry?.email?.trim().toLowerCase() || undefined;
if (callerEmail !== undefined && cooledEmail !== undefined) return callerEmail === cooledEmail;
return true;
}

function captureObservedMainWriter(): MainQuotaWriter | undefined {
const identityKey = getObservedMainQuotaIdentityKey();
return identityKey === undefined ? undefined : {
Expand Down Expand Up @@ -894,6 +943,14 @@ 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
&& !callerIsCooledPoolAccount(headers, config, accountId)) {
return await resolveCallerOwnedMainContext();
Comment thread
lidge-jun marked this conversation as resolved.
}
throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
}
}
Expand Down
26 changes: 21 additions & 5 deletions src/codex/reserve-availability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,30 @@ function owned(token: Token, writer: MainQuotaWriter): boolean {
function record(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function userId(token: string): string | undefined {
/**
* The ChatGPT per-user identity carried by a native credential, plus whether the token's own two
* encodings of it disagree. Precedence stays on the RAW claims, so an empty or non-string
* `chatgpt_user_id` still blocks the `user_id` fallback exactly as before; `conflict` is a
* separate observation for callers that must fail closed on an ambiguous identity.
*/
export function nativeUserIdClaims(token: string): { userId: string | undefined; conflict: boolean } {
const none = { userId: undefined, conflict: false };
try {
const payload: unknown = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
const auth = record(payload) ? payload["https://api.openai.com/auth"] : undefined;
if (!record(auth)) return;
const value = auth.chatgpt_user_id ?? auth.user_id;
return typeof value === "string" && value.length > 0 ? value : undefined;
} catch { return; }
if (!record(auth)) return none;
const named = (value: unknown): string | undefined =>
typeof value === "string" && value.length > 0 ? value : undefined;
const primary = named(auth.chatgpt_user_id);
const secondary = named(auth.user_id);
return {
userId: named(auth.chatgpt_user_id ?? auth.user_id),
conflict: primary !== undefined && secondary !== undefined && primary !== secondary,
};
} catch { return none; }
}
function userId(token: string): string | undefined {
return nativeUserIdClaims(token).userId;
}
function identityMatches(data: WhamUsageResponse, token: Token): boolean {
if (data.account_id != null && data.account_id !== token.chatgptAccountId) return false;
Expand Down
142 changes: 142 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,147 @@ 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("cooldown caller-main fallback never resurrects the cooled subscription", async () => {
const now = 1_800_000_000_000;
const originalNow = Date.now;
const cfg = { ...config(), autoSwitchThreshold: 0 };
// config() registers pool-a with email pool@example.test and workspace account pool_acc.
const callerJwt = (email?: string) => `header.${Buffer.from(JSON.stringify({
exp: Math.floor(now / 1000) + 86_400,
...(email ? { email } : {}),
"https://api.openai.com/auth": { chatgpt_account_id: "pool_acc" },
})).toString("base64url")}.signature`;
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" };
const resolve = (headers: Headers) => resolveCodexAuthContext(headers, cfg, "pool", options);

// The cooled account's exact materialized credential cannot use the fallback.
await expect(resolve(new Headers({
authorization: "Bearer pool_token", "chatgpt-account-id": "pool_acc",
}))).rejects.toBeInstanceOf(CodexAccountCooldownError);
// A rotated token of the same account (same workspace id and email) is still that subscription.
await expect(resolve(new Headers({
authorization: `Bearer ${callerJwt("pool@example.test")}`,
}))).rejects.toBeInstanceOf(CodexAccountCooldownError);
// A distinct team member on the shared workspace account id may serve the request.
await expect(resolve(new Headers({
authorization: `Bearer ${callerJwt("teammate@example.test")}`,
}))).resolves.toMatchObject({ kind: "main", accountId: null });
// An unreadable caller identity fails closed.
await expect(resolve(new Headers({
authorization: "Bearer opaque-caller-token",
}))).rejects.toBeInstanceOf(CodexAccountCooldownError);
// The workspace account id without a readable email cannot be distinguished: fail closed.
await expect(resolve(new Headers({
authorization: `Bearer ${callerJwt()}`,
}))).rejects.toBeInstanceOf(CodexAccountCooldownError);

// Nothing mutated the cooldown or the Pool selection.
expect(cfg.activeCodexAccountId).toBe("pool-a");
expect(getCodexQuotaHealthSnapshot("pool-a", "shared")).toEqual(cooldown);
} finally {
Date.now = originalNow;
}
});

test("cooldown caller-main fallback follows the stable user id, not the recorded email", async () => {
const now = 1_800_000_000_000;
const originalNow = Date.now;
const cfg = { ...config(), autoSwitchThreshold: 0 };
// config() registers pool-a with email pool@example.test on workspace account pool_acc.
const jwt = (claims: Record<string, unknown>, email?: string) => `header.${Buffer.from(JSON.stringify({
exp: Math.floor(now / 1000) + 86_400,
...(email ? { email } : {}),
"https://api.openai.com/auth": { chatgpt_account_id: "pool_acc", ...claims },
})).toString("base64url")}.signature`;
const storeCooled = (accessToken: string) => saveCodexAccountCredential("pool-a", {
accessToken, refreshToken: "pool_refresh",
expiresAt: now + 24 * 60 * 60_000, chatgptAccountId: "pool_acc",
});
storeCooled(jwt({ chatgpt_user_id: "user-cooled" }, "pool@example.test"));
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" };
const resolve = (headers: Headers) => resolveCodexAuthContext(headers, cfg, "pool", options);

// Distinct members of one workspace account, with no email claim anywhere to separate them.
await expect(resolve(new Headers({
authorization: `Bearer ${jwt({ chatgpt_user_id: "user-teammate" })}`,
}))).resolves.toMatchObject({ kind: "main", accountId: null });
// The same user stays inside its own cooldown after an email change and a token rotation.
await expect(resolve(new Headers({
authorization: `Bearer ${jwt({ chatgpt_user_id: "user-cooled" }, "renamed@example.test")}`,
}))).rejects.toBeInstanceOf(CodexAccountCooldownError);

// A stored credential whose own user-id claims disagree identifies nobody, so even a caller
// the email rule would have waved through as a teammate fails closed.
storeCooled(jwt({ chatgpt_user_id: "user-cooled", user_id: "user-other" }, "pool@example.test"));
await expect(resolve(new Headers({
authorization: `Bearer ${jwt({ chatgpt_user_id: "user-teammate" }, "teammate@example.test")}`,
}))).rejects.toBeInstanceOf(CodexAccountCooldownError);

expect(cfg.activeCodexAccountId).toBe("pool-a");
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
Loading
Loading