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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ export function getCodexPlanLabel(isCodex: boolean, providerSpecificData: unknow
providerSpecificData && typeof providerSpecificData === "object"
? (providerSpecificData as Record<string, unknown>)
: {};
const raw = record.chatgptPlanType;
return typeof raw === "string" ? raw.trim() : "";
const trimmed = (value: unknown) => (typeof value === "string" ? value.trim() : "");
return trimmed(record.chatgptPlanType) || trimmed(record.workspacePlanType);
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,8 @@ export default function QuotaCard({
quota?.plan ?? null,
connection.providerSpecificData ?? null,
connection.provider
)
),
connection.provider
),
[quota?.plan, connection.providerSpecificData, connection.provider]
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ export default function ProviderLimits({
const tierByConnection = useMemo(() => {
const out: Record<string, ReturnType<typeof normalizePlanTier>> = {};
for (const conn of sortedConnections) {
out[conn.id] = normalizePlanTier(resolvedPlanByConnection[conn.id]);
out[conn.id] = normalizePlanTier(resolvedPlanByConnection[conn.id], conn.provider);
}
return out;
}, [sortedConnections, resolvedPlanByConnection]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,19 @@ function formatUnknownPlanLabel(raw: string) {
.join(" ");
}

function matchCodexPlanTier(raw: string, upper: string) {
if (upper === "PROLITE") {
return { key: "pro", label: "Pro Standard", variant: "success", rank: 3, raw };
}
if (upper === "PRO" || upper === "PROEXTRA" || upper === "PRO_EXTRA") {
return { key: "pro", label: "Pro Extra", variant: "success", rank: 3, raw };
}
if (upper === "PROMAX" || upper === "PRO_MAX") {
return { key: "pro", label: "Pro Max", variant: "success", rank: 3, raw };
}
return null;
}

function matchClaudePlanTier(raw: string, upper: string) {
const match = upper.match(/(?:DEFAULT_)?CLAUDE_(MAX|PRO|TEAM|ENTERPRISE|FREE)(?:_(\d+X))?/);
if (!match) return null;
Expand Down Expand Up @@ -308,8 +321,10 @@ function matchFreePlanTier(raw: string, upper: string) {
/**
* Normalize provider-specific plan labels into a shared tier taxonomy.
* Supported tiers: enterprise, business, team, ultra, pro, plus, lite, free, unknown.
* Codex's Pro Standard/Extra/Max variants are only recognized when `provider === "codex"`;
* a plain "Pro" of any other provider keeps the generic "Pro" label.
*/
export function normalizePlanTier(plan) {
export function normalizePlanTier(plan, provider?: string | null) {
const raw = typeof plan === "string" ? plan.trim() : "";
if (!raw) return unknownPlanTier(null);

Expand All @@ -321,6 +336,7 @@ export function normalizePlanTier(plan) {
// Match Anthropic bootstrap strings (claude_max, default_claude_max_20x, etc.)
// before the generic PRO/TEAM checks so underscored values don't fall through.
const matched =
(provider === "codex" ? matchCodexPlanTier(raw, upper) : null) ||
matchClaudePlanTier(raw, upper) ||
matchKeywordPlanTier(raw, upper) ||
matchTokenPlanTier(raw, upper) ||
Expand Down
23 changes: 23 additions & 0 deletions tests/unit/codex-plan-label-2570.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,29 @@ test("getCodexPlanLabel returns empty string when chatgptPlanType is missing/bla
assert.equal(getCodexPlanLabel(true, undefined), "");
});

test("getCodexPlanLabel falls back to workspacePlanType when chatgptPlanType is missing or blank (#15161)", () => {
assert.equal(getCodexPlanLabel(true, { workspacePlanType: " prolite " }), "prolite");
assert.equal(
getCodexPlanLabel(true, { chatgptPlanType: "", workspacePlanType: "promax" }),
"promax"
);
assert.equal(
getCodexPlanLabel(true, { chatgptPlanType: " ", workspacePlanType: "plus" }),
"plus"
);
assert.equal(
getCodexPlanLabel(true, { chatgptPlanType: null, workspacePlanType: "team" }),
"team"
);
// chatgptPlanType still wins when it is set.
assert.equal(
getCodexPlanLabel(true, { chatgptPlanType: "Pro", workspacePlanType: "plus" }),
"Pro"
);
// Not Codex -> always empty, even with a workspace plan.
assert.equal(getCodexPlanLabel(false, { workspacePlanType: "plus" }), "");
});

test("resolvePlanValue falls back to the persisted Codex chatgptPlanType when the live plan is unknown", () => {
// Reproduces the exact shape open-sse/services/usage/codex.ts returns when
// the upstream Codex usage endpoint omits plan_type/planType.
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/provider-limits-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,37 @@ test("paid individual tiers use non-gray badge variants", () => {
assert.equal(providerLimitUtils.normalizePlanTier("Free").variant, "default");
});

test("Codex Pro plan variants (prolite, pro, promax) normalize to Pro tier (#15161)", () => {
const prolite = providerLimitUtils.normalizePlanTier("prolite", "codex");
assert.equal(prolite.key, "pro");
assert.equal(prolite.label, "Pro Standard");
assert.equal(prolite.variant, "success");

const pro = providerLimitUtils.normalizePlanTier("pro", "codex");
assert.equal(pro.key, "pro");
assert.equal(pro.label, "Pro Extra");
assert.equal(pro.variant, "success");

const promax = providerLimitUtils.normalizePlanTier("promax", "codex");
assert.equal(promax.key, "pro");
assert.equal(promax.label, "Pro Max");
assert.equal(promax.variant, "success");
});

test("Codex Pro variants are scoped to Codex: other providers keep the generic Pro label (#15161)", () => {
for (const provider of ["github", "gemini", "minimax", undefined]) {
for (const plan of ["Pro", "pro", "PRO"]) {
const tier = providerLimitUtils.normalizePlanTier(plan, provider);
assert.equal(tier.key, "pro", `${provider}/${plan} key`);
assert.equal(tier.label, "Pro", `${provider}/${plan} label`);
}
}
assert.equal(providerLimitUtils.normalizePlanTier("Claude Pro", "claude").label, "Pro");
// Codex-only vocabulary does not leak into other providers either.
assert.notEqual(providerLimitUtils.normalizePlanTier("prolite", "github").label, "Pro Standard");
assert.notEqual(providerLimitUtils.normalizePlanTier("promax", "github").label, "Pro Max");
});

test("Codex workspacePlanType is used when live plan is missing or unknown", () => {
const resolvedPlan = providerLimitUtils.resolvePlanValue("unknown", {
workspacePlanType: "plus",
Expand Down
Loading