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
29 changes: 16 additions & 13 deletions src/codex/auth-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<T extends Omit<StoredAccountQuota, "updatedAt"> | 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 } : {}),
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
Expand All @@ -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(
Expand Down
8 changes: 5 additions & 3 deletions src/codex/auth-collision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down
15 changes: 15 additions & 0 deletions src/codex/plan.ts
Original file line number Diff line number Diff line change
@@ -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";
}
12 changes: 6 additions & 6 deletions src/codex/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,7 +37,7 @@ let persistTimer: ReturnType<typeof setTimeout> | 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;
Expand Down Expand Up @@ -72,7 +73,7 @@ export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;

export function isCodexQuotaExhausted(
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent"> | null,
plan?: string | null,
plan?: unknown,
): boolean {
if (!quota) return false;
const values = codexQuotaWindowForPlan(plan) === "monthly"
Expand All @@ -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<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow"> | 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
Expand Down
6 changes: 3 additions & 3 deletions src/codex/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
11 changes: 7 additions & 4 deletions src/providers/codex-capacity.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { codexPlanKey, codexPlanValue } from "../codex/plan";

export const CODEX_CONFIGURED_CAPACITY_WEIGHTS = {
plus: 1,
business: 1,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, MutableWindow>();
Expand Down
3 changes: 2 additions & 1 deletion src/providers/quota.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
29 changes: 28 additions & 1 deletion tests/codex-auth-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }>;
};
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: [
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions tests/codex-auth-collision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
3 changes: 3 additions & 0 deletions tests/codex-cooldown-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
5 changes: 5 additions & 0 deletions tests/codex-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 11 additions & 1 deletion tests/provider-capacity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodexCapacityAccount> & { weeklyResetAt?: number; monthlyPercent?: number; monthlyResetAt?: number } = {},
): CodexCapacityAccount => ({
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading