From 095a9a5b7de0d651c4e7bca656ef9fb235d0fdae Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 10 Aug 2026 02:43:37 +0900 Subject: [PATCH] fix(codex): tolerate malformed account plan values --- src/codex/auth-api.ts | 29 ++++++++++-------- src/codex/auth-collision.ts | 8 +++-- src/codex/plan.ts | 15 +++++++++ src/codex/quota.ts | 12 ++++---- src/codex/routing.ts | 6 ++-- src/providers/codex-capacity.ts | 11 ++++--- src/providers/quota.ts | 3 +- tests/codex-auth-api.test.ts | 29 +++++++++++++++++- tests/codex-auth-collision.test.ts | 15 +++++++++ tests/codex-cooldown-recovery.test.ts | 3 ++ tests/codex-routing.test.ts | 5 +++ tests/provider-capacity.test.ts | 12 +++++++- tests/provider-quota.test.ts | 44 +++++++++++++++++++++++++++ 13 files changed, 160 insertions(+), 32 deletions(-) create mode 100644 src/codex/plan.ts diff --git a/src/codex/auth-api.ts b/src/codex/auth-api.ts index 1d63a2d50f..ac1b81a091 100644 --- a/src/codex/auth-api.ts +++ b/src/codex/auth-api.ts @@ -58,6 +58,7 @@ import { parseAccountPriority, } from "./pool-rotation"; import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision"; +import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "./plan"; export { checkAccountIdCollision, getMainChatgptAccountId } from "./auth-collision"; export { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth } from "./account-runtime-state"; @@ -208,16 +209,11 @@ function codexAccountPersistenceConflict( : undefined; } -function isThirtyDayOnlyPlan(plan: string | null | undefined): boolean { - const normalized = plan?.trim().toLowerCase(); - return normalized === "go" || normalized === "free"; -} - function quotaForPlan | StoredAccountQuota | null>( quota: T, - plan: string | null | undefined, + plan: unknown, ): T { - if (!quota || !isThirtyDayOnlyPlan(plan)) return quota; + if (!quota || !isThirtyDayOnlyCodexPlan(plan)) return quota; return { ...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}), ...(quota.monthlyResetAt !== undefined ? { monthlyResetAt: quota.monthlyResetAt } : {}), @@ -233,14 +229,15 @@ function poolAccountDto( paused: boolean, priority: number, ): CodexAuthAccountDto { - const quota = quotaForPlan(quotaResult.quota, account.plan); + const plan = codexPlanValue(account.plan); + const quota = quotaForPlan(quotaResult.quota, plan); const needsReauth = !hasCredential || quotaResult.needsReauth || isAccountNeedsReauth(account.id); const health = projectCodexAccountHealth({ accountId: account.id, needsReauth }); return { id: account.id, email: maskEmail(account.email) ?? account.email, ...(account.alias !== undefined ? { alias: account.alias } : {}), - ...(account.plan !== undefined ? { plan: account.plan } : {}), + ...(plan !== undefined ? { plan } : {}), ...(account.logLabel !== undefined ? { logLabel: account.logLabel } : {}), isMain: false, paused, @@ -398,7 +395,7 @@ const POOL_CACHE_TTL = 5 * 60_000; const POOL_QUOTA_REFRESH_CONCURRENCY = 4; function nonEmptyPlan(value: unknown): string | null { - return typeof value === "string" && value.trim() !== "" ? value : null; + return codexPlanValue(value) ?? null; } function isRuntimeConfig(config: OcxConfig): boolean { @@ -1334,7 +1331,7 @@ export async function handleCodexAuthAPI( if (url.pathname === "/api/codex-auth/accounts" && req.method === "POST") { if (!isUnverifiedCodexImportEnabled()) return manualImportDisabledResponse(); - let body: { id: string; email: string; plan?: string; accessToken: string; refreshToken: string; chatgptAccountId: string }; + let body: { id: string; email: string; plan?: unknown; accessToken: string; refreshToken: string; chatgptAccountId: string }; try { body = (await req.json()) as typeof body; } catch { return jsonResponse({ error: "Invalid JSON" }, 400); } if (!body.id || !body.email || !body.accessToken || !body.refreshToken || !body.chatgptAccountId) { return jsonResponse({ error: "Missing required fields" }, 400); @@ -1349,8 +1346,9 @@ export async function handleCodexAuthAPI( const preflightConflict = codexAccountPersistenceConflict(runtimeConfig, body.id, "create"); if (preflightConflict) return jsonResponse({ error: preflightConflict }, 400); // 1.1: Duplicate check is scoped by personal vs workspace plan bucket. + const plan = codexPlanValue(body.plan); const derivedAccountId = extractAccountId(undefined, body.accessToken) ?? body.chatgptAccountId; - const collision = checkAccountIdCollision(derivedAccountId, body.email, body.plan); + const collision = checkAccountIdCollision(derivedAccountId, body.email, plan); if (collision.collision) { return jsonResponse({ error: collision.reason }, 400); } @@ -1363,7 +1361,12 @@ export async function handleCodexAuthAPI( const commitConflict = codexAccountPersistenceConflict(latestConfig, body.id, "create"); if (commitConflict) return jsonResponse({ error: commitConflict }, 400); const addedAccount = withCodexAccountLogLabel( - { id: body.id, email: body.email, plan: body.plan, isMain: false }, + { + id: body.id, + email: body.email, + ...(plan !== undefined ? { plan } : {}), + isMain: false, + }, latestConfig.codexAccounts ?? [], ); const persistence = persistNewCodexAccount( diff --git a/src/codex/auth-collision.ts b/src/codex/auth-collision.ts index a7d10e7c97..52c9242e8c 100644 --- a/src/codex/auth-collision.ts +++ b/src/codex/auth-collision.ts @@ -5,6 +5,7 @@ import { loadConfig } from "../config"; import { resolveCodexHomeDir } from "./home"; import { extractAccountId } from "../oauth/chatgpt"; import { isSelectableCodexPoolAccount } from "./account-id"; +import { codexPlanKey } from "./plan"; export interface CodexTokens { access_token: string; @@ -78,8 +79,9 @@ function normalizedEmail(email: string | undefined | null): string | null { return trimmed || null; } -function isWorkspacePlan(plan: string | undefined | null): boolean { - return !!plan && /team|business|enterprise|workspace|edu/i.test(plan); +function isWorkspacePlan(plan: unknown): boolean { + const key = codexPlanKey(plan); + return !!key && /team|business|enterprise|workspace|edu/.test(key); } // Main login and managed pool accounts are separate duplicate buckets. @@ -88,7 +90,7 @@ function isWorkspacePlan(plan: string | undefined | null): boolean { export function checkAccountIdCollision( chatgptAccountId: string, email?: string | null, - plan?: string | null, + plan?: unknown, excludeAccountId?: string | null, ): { collision: true; reason: string } | { collision: false } { const candidateEmail = normalizedEmail(email); diff --git a/src/codex/plan.ts b/src/codex/plan.ts new file mode 100644 index 0000000000..6acb2d1b46 --- /dev/null +++ b/src/codex/plan.ts @@ -0,0 +1,15 @@ +/** Preserve user/provider plan labels only when they are usable strings. */ +export function codexPlanValue(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + return value.trim() ? value : undefined; +} + +/** Case-insensitive key used by quota, capacity, and collision policy. */ +export function codexPlanKey(value: unknown): string | undefined { + return codexPlanValue(value)?.trim().toLowerCase(); +} + +export function isThirtyDayOnlyCodexPlan(value: unknown): boolean { + const key = codexPlanKey(value); + return key === "go" || key === "free"; +} diff --git a/src/codex/quota.ts b/src/codex/quota.ts index 562e7082e3..5ca9a9b4e0 100644 --- a/src/codex/quota.ts +++ b/src/codex/quota.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { atomicWriteFile, getConfigDir } from "../config"; import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper"; +import { isThirtyDayOnlyCodexPlan } from "./plan"; export type StoredAccountQuota = { weeklyPercent?: number; @@ -36,7 +37,7 @@ let persistTimer: ReturnType | null = null; export type WhamUsageResponse = { email?: string | null; - plan_type?: string | null; + plan_type?: unknown; rate_limit?: { // Live WHAM payloads send explicit nulls for absent windows (issue #315 repro). primary_window?: WhamUsageWindow | null; @@ -72,7 +73,7 @@ export const CODEX_EXHAUSTED_USAGE_PERCENT = 100; export function isCodexQuotaExhausted( quota: Pick | null, - plan?: string | null, + plan?: unknown, ): boolean { if (!quota) return false; const values = codexQuotaWindowForPlan(plan) === "monthly" @@ -98,14 +99,13 @@ export function isCodexQuotaExhausted( * everything else (including an absent plan) reports weekly. Recovery reads the * window the parser actually wrote rather than second-guessing it. */ -export function codexQuotaWindowForPlan(plan?: string | null): "monthly" | "weekly" { - const normalized = plan?.trim().toLowerCase(); - return normalized === "go" || normalized === "free" ? "monthly" : "weekly"; +export function codexQuotaWindowForPlan(plan?: unknown): "monthly" | "weekly" { + return isThirtyDayOnlyCodexPlan(plan) ? "monthly" : "weekly"; } export function isCompleteCodexQuotaRecoverySnapshot( quota: Pick | null, - plan?: string | null, + plan?: unknown, ): boolean { if (!quota || isCodexQuotaExhausted(quota, plan)) return false; // Recovery still fails closed on MISSING EVIDENCE — a credits-only or windowless payload diff --git a/src/codex/routing.ts b/src/codex/routing.ts index ccdb486eed..2e287adf29 100644 --- a/src/codex/routing.ts +++ b/src/codex/routing.ts @@ -18,6 +18,7 @@ import { selectPriorityTier, } from "./pool-rotation"; import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota"; +import { isThirtyDayOnlyCodexPlan } from "./plan"; import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account"; import { isSelectableCodexPoolAccount } from "./account-id"; import type { OcxConfig } from "../types"; @@ -314,10 +315,9 @@ function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void { export function computeCodexUsageScore(quota: { weeklyPercent?: number; monthlyPercent?: number; -} | null, plan?: string | null): number { +} | null, plan?: unknown): number { if (!quota) return CODEX_UNKNOWN_USAGE_SCORE; - const normalizedPlan = plan?.trim().toLowerCase(); - if (normalizedPlan === "go" || normalizedPlan === "free") { + if (isThirtyDayOnlyCodexPlan(plan)) { return typeof quota.monthlyPercent === "number" && Number.isFinite(quota.monthlyPercent) ? quota.monthlyPercent : CODEX_UNKNOWN_USAGE_SCORE; diff --git a/src/providers/codex-capacity.ts b/src/providers/codex-capacity.ts index 7ced2ce9bb..2cb2dc4864 100644 --- a/src/providers/codex-capacity.ts +++ b/src/providers/codex-capacity.ts @@ -1,3 +1,5 @@ +import { codexPlanKey, codexPlanValue } from "../codex/plan"; + export const CODEX_CONFIGURED_CAPACITY_WEIGHTS = { plus: 1, business: 1, @@ -22,7 +24,7 @@ export type CodexCapacityQuota = { export interface CodexCapacityAccount { isMain: boolean; active?: boolean; - plan?: string | null; + plan?: unknown; paused: boolean; needsReauth?: boolean; quota: CodexCapacityQuota | null; @@ -82,8 +84,8 @@ type MutableWindow = { oldestUpdatedAt: number; }; -function configuredWeight(plan: string | null | undefined): number | undefined { - const normalized = plan?.trim().toLowerCase(); +function configuredWeight(plan: unknown): number | undefined { + const normalized = codexPlanKey(plan); return normalized && Object.hasOwn(CODEX_CONFIGURED_CAPACITY_WEIGHTS, normalized) ? CODEX_CONFIGURED_CAPACITY_WEIGHTS[normalized as keyof typeof CODEX_CONFIGURED_CAPACITY_WEIGHTS] : undefined; @@ -168,9 +170,10 @@ export function aggregateCodexPoolCapacity( const current = accounts.find(account => account.active) ?? accounts.find(account => account.isMain) ?? accounts[0]; + const currentPlan = codexPlanValue(current?.plan); const currentAccount = current ? { isMain: current.isMain, - ...(current.plan !== undefined ? { plan: current.plan } : {}), + ...(currentPlan !== undefined ? { plan: currentPlan } : {}), quota: currentQuotaForDisplay(current, now), } : undefined; const windows = new Map(); diff --git a/src/providers/quota.ts b/src/providers/quota.ts index b253c27fec..5164991c0c 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -6,6 +6,7 @@ import { } from "../codex/auth-api"; import { isMainAccountIdentityGenerationLive } from "../codex/main-account-cache"; import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account"; +import { codexPlanKey } from "../codex/plan"; import { resolveEnvValue } from "../config"; import { getValidAccessToken, getValidAccessTokenForAccount } from "../oauth"; import { getAccountCredential, getAccountSet, getCredential } from "../oauth/store"; @@ -168,7 +169,7 @@ function cacheKeyWithAggregationState( const rows = snapshot.accounts.map(account => ({ isMain: account.isMain, active: account.id === activeId, - plan: account.plan?.trim().toLowerCase() ?? null, + plan: codexPlanKey(account.plan) ?? null, paused: account.paused, needsReauth: account.needsReauth === true, quota: quotaSignatureValue(account.quota as CodexCapacityQuota | null), diff --git a/tests/codex-auth-api.test.ts b/tests/codex-auth-api.test.ts index 1000dc8f67..f6d447a93a 100644 --- a/tests/codex-auth-api.test.ts +++ b/tests/codex-auth-api.test.ts @@ -858,6 +858,32 @@ describe("codex-auth API", () => { expect(data.accounts.find(a => a.id === "pool-mask")?.email).toBe("p***n@example.test"); }); + test("GET /api/codex-auth/accounts omits a malformed persisted plan", async () => { + const config = makeConfig({ + codexAccounts: [ + { id: "pool-invalid-plan", email: "invalid@example.test", plan: { tier: "go" }, isMain: false }, + ] as unknown as OcxConfig["codexAccounts"], + }); + saveCodexAccountCredential("pool-invalid-plan", { + accessToken: "access-invalid-plan", + refreshToken: "refresh-invalid-plan", + expiresAt: Date.now() + 5 * 60_000, + chatgptAccountId: "acct-invalid-plan", + }); + updateAccountQuota("pool-invalid-plan", 91, 111, 33, 333); + + const req = new Request("http://localhost/api/codex-auth/accounts", { method: "GET" }); + const resp = await handleCodexAuthAPI(req, new URL(req.url), config); + const data = await resp!.json() as { + accounts: Array<{ id: string; plan?: unknown; quota?: Record }>; + }; + const account = data.accounts.find(row => row.id === "pool-invalid-plan"); + + expect(resp?.status).toBe(200); + expect(account).not.toHaveProperty("plan"); + expect(account?.quota).toMatchObject({ weeklyPercent: 91, monthlyPercent: 33 }); + }); + test("GET /api/codex-auth/accounts exposes only 30d quota for go and free plans", async () => { const config = makeConfig({ codexAccounts: [ @@ -2259,12 +2285,13 @@ describe("codex-auth API", () => { const req = new Request("http://localhost/api/codex-auth/accounts", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(manualImportBody({ id: "manual-enabled" })), + body: JSON.stringify(manualImportBody({ id: "manual-enabled", plan: { tier: "go" } })), }); const resp = await handleCodexAuthAPI(req, new URL(req.url), config); expect(resp!.status).toBe(200); expect(config.codexAccounts?.map(a => a.id)).toEqual(["manual-enabled"]); + expect(config.codexAccounts?.[0]).not.toHaveProperty("plan"); expect(config.codexAccounts?.[0]?.logLabel).toMatch(CODEX_ACCOUNT_LOG_LABEL_RE); expect(getCodexAccountCredential("manual-enabled")).toMatchObject({ accessToken: "access-manual-test", diff --git a/tests/codex-auth-collision.test.ts b/tests/codex-auth-collision.test.ts index 7dddea9e6d..1e74162b4c 100644 --- a/tests/codex-auth-collision.test.ts +++ b/tests/codex-auth-collision.test.ts @@ -46,6 +46,21 @@ function seedAccount(id: string, email: string, chatgptAccountId: string, plan?: } describe("codex auth account collision", () => { + test("treats a non-string plan as an unknown personal plan", () => { + saveConfig({ + port: 10100, + providers: {}, + defaultProvider: "openai", + codexAccounts: [], + } as OcxConfig); + + expect(checkAccountIdCollision( + "malformed-plan-account", + "member@example.test", + { toString: 1 }, + )).toEqual({ collision: false }); + }); + test("allows different team members that share a ChatGPT account id", async () => { seedAccount("team-member-a", "member-a@example.test", "shared-team-account"); diff --git a/tests/codex-cooldown-recovery.test.ts b/tests/codex-cooldown-recovery.test.ts index c34eff6c6a..22864b02a6 100644 --- a/tests/codex-cooldown-recovery.test.ts +++ b/tests/codex-cooldown-recovery.test.ts @@ -364,6 +364,9 @@ describe("Codex cooldown recovery worker", () => { } expect(codexQuotaWindowForPlan(undefined)).toBe("weekly"); expect(codexQuotaWindowForPlan("")).toBe("weekly"); + for (const malformed of [{ tier: "go" }, 1, true]) { + expect(codexQuotaWindowForPlan(malformed)).toBe("weekly"); + } // "free_workspace" is not "free": only the exact names take the monthly window. expect(codexQuotaWindowForPlan("free_workspace")).toBe("weekly"); diff --git a/tests/codex-routing.test.ts b/tests/codex-routing.test.ts index 1802aa8c5b..162697bd98 100644 --- a/tests/codex-routing.test.ts +++ b/tests/codex-routing.test.ts @@ -181,6 +181,11 @@ describe("codex routing", () => { expect(computeCodexUsageScore({ weeklyPercent: 1 }, "go")).toBe(CODEX_UNKNOWN_USAGE_SCORE); }); + test("usage score treats non-string plans as unknown weekly plans", () => { + expect(computeCodexUsageScore({ weeklyPercent: 27, monthlyPercent: 12 }, { tier: "go" })).toBe(27); + expect(computeCodexUsageScore({ weeklyPercent: 27, monthlyPercent: 12 }, 1)).toBe(27); + }); + test("usage score treats unknown quota conservatively", () => { expect(computeCodexUsageScore(null)).toBe(CODEX_UNKNOWN_USAGE_SCORE); expect(computeCodexUsageScore({})).toBe(CODEX_UNKNOWN_USAGE_SCORE); diff --git a/tests/provider-capacity.test.ts b/tests/provider-capacity.test.ts index e45afb45b7..42508e8d59 100644 --- a/tests/provider-capacity.test.ts +++ b/tests/provider-capacity.test.ts @@ -3,7 +3,7 @@ import { aggregateCodexPoolCapacity, CODEX_CAPACITY_MAX_QUOTA_AGE_MS, type Codex const NOW = 1_800_000_000_000; const account = ( - plan: string | null, + plan: unknown, weeklyPercent: number | undefined, options: Partial & { weeklyResetAt?: number; monthlyPercent?: number; monthlyResetAt?: number } = {}, ): CodexCapacityAccount => ({ @@ -179,6 +179,16 @@ describe("configured-weight Codex pool capacity", () => { expect(Number.isFinite(result.quota?.weeklyPercent)).toBe(true); }); + test("non-string plans are excluded and never exposed in current-account metadata", () => { + const result = aggregateCodexPoolCapacity([ + account({ tier: "pro" }, 20, { active: true, isMain: true }), + ], NOW); + expect(result.quota).toBeNull(); + expect(result.aggregation).toMatchObject({ unknownPlanAccounts: 1, includedAccounts: 0 }); + expect(result.aggregation?.currentAccount).not.toHaveProperty("plan"); + expect(result.aggregation?.currentAccount?.quota?.weeklyPercent).toBe(20); + }); + test("all-stale rows expose incomplete coverage without an aggregate window", () => { const rows = [account("pro", 80, { active: true, isMain: true }), account("prolite", 20)]; for (const row of rows) { diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 7d58f18198..d4f7d7d15e 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -1442,6 +1442,50 @@ describe("fetchProviderQuotaReports", () => { expect(JSON.stringify(openai?.aggregation)).not.toMatch(/(?:total|consumed|remaining)Weight|projectedUsedPercent/i); }); + test("pool reports tolerate a malformed persisted plan through cache and aggregation", async () => { + saveCodexAccountCredential("added", { + accessToken: "added-access", + refreshToken: "added-refresh", + expiresAt: Date.now() + 3600_000, + chatgptAccountId: "added-chatgpt-id", + }); + const config = testConfig(); + config.providers = { openai: config.providers.openai }; + config.codexAccounts = [{ + id: "added", + email: "a@example.test", + plan: { tier: "pro" } as never, + isMain: false, + }]; + config.activeCodexAccountId = "added"; + let calls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + calls += 1; + const added = (init?.headers as Record | undefined)?.["ChatGPT-Account-Id"] === "added-chatgpt-id"; + return new Response(JSON.stringify({ + plan_type: added ? { tier: "pro" } : "plus", + rate_limit: { secondary_window: { used_percent: added ? 77 : 11, reset_at: 1_999_000_000 } }, + }), { status: 200, headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + const refreshed = await fetchProviderQuotaReports(config, true); + const openai = refreshed.reports.find(row => row.provider === "openai"); + expect(openai?.quota.weeklyPercent).toBe(11); + expect(openai?.aggregation).toMatchObject({ + includedAccounts: 1, + excludedAccounts: 1, + unknownPlanAccounts: 1, + incomplete: true, + currentAccount: { quota: { weeklyPercent: 77 } }, + }); + expect(openai?.aggregation?.currentAccount).not.toHaveProperty("plan"); + expect(calls).toBe(2); + + const cached = await fetchProviderQuotaReports(config); + expect(cached.reports[0]?.aggregation?.unknownPlanAccounts).toBe(1); + expect(calls).toBe(2); + }); + test("one forced Pool refresh probes each account once", async () => { saveCodexAccountCredential("added", { accessToken: "added-access", refreshToken: "added-refresh",