From 094e509f042f573cf4104d91562c249b2310cb0c Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:23:43 +0900 Subject: [PATCH] fix(codex): fall back to caller main during pool cooldown --- .../content/docs/guides/codex-integration.md | 7 ++++ .../docs/ko/guides/codex-integration.md | 6 +++ src/codex/auth-context.ts | 7 ++++ .../codex-auth-context.test.ts | 39 +++++++++++++++++++ .../main-account-hard-lock-auth.test.ts | 30 +++++++++++++- 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index 2944208279..e81d25de7f 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -20,6 +20,13 @@ plus `openai-apikey/` 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 diff --git a/docs-site/src/content/docs/ko/guides/codex-integration.md b/docs-site/src/content/docs/ko/guides/codex-integration.md index 41f90537cd..a54432edc8 100644 --- a/docs-site/src/content/docs/ko/guides/codex-integration.md +++ b/docs-site/src/content/docs/ko/guides/codex-integration.md @@ -7,6 +7,12 @@ opencodex는 Codex가 읽는 두 가지, 즉 설정(`$CODEX_HOME/config.toml`, 프록시는 bare `openai` Codex 로그인 경로 하나와 Pool(기본) 및 Direct 계정 모드, 그리고 설정된 API 키용 `openai-apikey/`을 제공합니다. 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를 바라보게 합니다. diff --git a/src/codex/auth-context.ts b/src/codex/auth-context.ts index 2f319b3144..22196583c8 100644 --- a/src/codex/auth-context.ts +++ b/src/codex/auth-context.ts @@ -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); } } diff --git a/tests/codex-integration/codex-auth-context.test.ts b/tests/codex-integration/codex-auth-context.test.ts index 73417b85f7..673655532b 100644 --- a/tests/codex-integration/codex-auth-context.test.ts +++ b/tests/codex-integration/codex-auth-context.test.ts @@ -55,6 +55,7 @@ import { CODEX_QUOTA_PROBE_INTERVAL_MS, clearCodexUpstreamHealth, clearThreadAccountMap, + getCodexQuotaHealthSnapshot, recordCodexUpstreamOutcome, resetCodexRoutingForManualSelection, } from "../../src/codex/routing"; @@ -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", diff --git a/tests/codex-integration/main-account-hard-lock-auth.test.ts b/tests/codex-integration/main-account-hard-lock-auth.test.ts index d0959817c1..5ffc0b6d3e 100644 --- a/tests/codex-integration/main-account-hard-lock-auth.test.ts +++ b/tests/codex-integration/main-account-hard-lock-auth.test.ts @@ -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"; @@ -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);