diff --git a/changelog.d/features/7210-codex-plan-labels.md b/changelog.d/features/7210-codex-plan-labels.md new file mode 100644 index 0000000000..0df8e21855 --- /dev/null +++ b/changelog.d/features/7210-codex-plan-labels.md @@ -0,0 +1 @@ +- **feat(dashboard):** show the Codex subscription plan label in provider connection rows and the quota view, falling back to the plan captured at OAuth import when the live usage endpoint doesn't report one. (thanks @CarmeloCampos) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/codexPlanLabel.ts b/src/app/(dashboard)/dashboard/providers/[id]/codexPlanLabel.ts new file mode 100644 index 0000000000..767bd651cd --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/codexPlanLabel.ts @@ -0,0 +1,19 @@ +/** + * Codex subscription plan label (e.g. "Plus", "Pro", "Team"), persisted on the + * connection's providerSpecificData.chatgptPlanType at OAuth import time (see + * src/lib/oauth/services/codexImport.ts). Returns "" when the connection is + * not Codex or the value is missing/blank — callers gate rendering on that. + * + * Kept in its own module (not providerPageHelpers.ts) because that file is + * frozen at its file-size ratchet cap (config/quality/file-size-baseline.json) + * and this helper is fully self-contained. + */ +export function getCodexPlanLabel(isCodex: boolean, providerSpecificData: unknown): string { + if (!isCodex) return ""; + const record = + providerSpecificData && typeof providerSpecificData === "object" + ? (providerSpecificData as Record) + : {}; + const raw = record.chatgptPlanType; + return typeof raw === "string" ? raw.trim() : ""; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx index 267cfda72e..72e9229c02 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx @@ -15,7 +15,12 @@ import { getCodexEffectiveServiceTier, type CodexGlobalServiceMode, } from "@/lib/providers/codexFastTier"; -import { normalizeCodexLimitPolicy, providerText, ERROR_TYPE_LABELS } from "../providerPageHelpers"; +import { + normalizeCodexLimitPolicy, + providerText, + ERROR_TYPE_LABELS, +} from "../providerPageHelpers"; +import { getCodexPlanLabel } from "../codexPlanLabel"; // --------------------------------------------------------------------------- // Types (exported so the client can reference them without re-importing) @@ -499,6 +504,7 @@ export default function ConnectionRow({ const claudeBlockExtraUsageEnabled = isClaude ? isClaudeExtraUsageBlockEnabled("claude", connection.providerSpecificData) : false; + const codexPlanLabel = getCodexPlanLabel(!!isCodex, connection.providerSpecificData); const cliproxyapiDeepMode = !!cliproxyapiEnabled; return ( @@ -540,6 +546,11 @@ export default function ConnectionRow({ {statusPresentation.statusLabel} + {codexPlanLabel && ( + + {codexPlanLabel} + + )} {/* T12: Token expiry status indicator (state-driven, no Date.now in render) */} {/* #5836: the red "Token Expired" badge is TERMINAL-only — for OAuth refresh-capable providers (Antigravity/Gemini) the access token lapses diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index 77058cdc82..bba6ead776 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -193,6 +193,12 @@ export function resolvePlanValue(plan, providerSpecificData) { psd.organizationRateLimitTier, psd.rateLimitTier, psd.organizationType, + // Codex OAuth bootstrap: chatgpt_plan_type is captured at import time + // (src/lib/oauth/services/codexImport.ts) and is the only source of the + // plan when the live Codex usage endpoint omits plan_type/planType (the + // usage service then reports the literal string "unknown" — see + // open-sse/services/usage/codex.ts). + psd.chatgptPlanType, ]; if (livePlan && normalizePlanTier(livePlan).key !== "free") { diff --git a/tests/unit/codex-plan-label-2570.test.ts b/tests/unit/codex-plan-label-2570.test.ts new file mode 100644 index 0000000000..2b22445266 --- /dev/null +++ b/tests/unit/codex-plan-label-2570.test.ts @@ -0,0 +1,50 @@ +// Port of upstream decolua/9router PR #2570 (feat(ui): show Codex plan labels +// in provider and quota views). +// +// Two independent gaps this closes: +// +// 1. providerPageHelpers.getCodexPlanLabel — the provider-detail ConnectionRow +// never surfaced the Codex subscription plan (persisted at OAuth import +// time in providerSpecificData.chatgptPlanType — see +// src/lib/oauth/services/codexImport.ts) anywhere in the row UI. +// +// 2. ProviderLimits/utils.resolvePlanValue — the quota-view plan badge +// machinery already existed (tierByConnection / QuotaCardHeader), but its +// persisted-metadata fallback list did not include chatgptPlanType. When +// the live Codex usage endpoint does not return a plan_type field (usage +// service falls back to the literal string "unknown" — see +// open-sse/services/usage/codex.ts), the badge fell through to "Unknown" +// instead of the plan captured at login. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { getCodexPlanLabel } from "@/app/(dashboard)/dashboard/providers/[id]/codexPlanLabel"; +import { resolvePlanValue } from "@/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils"; + +test("getCodexPlanLabel returns the trimmed chatgptPlanType for codex connections", () => { + assert.equal(getCodexPlanLabel(true, { chatgptPlanType: " Pro " }), "Pro"); +}); + +test("getCodexPlanLabel returns empty string when not a codex connection", () => { + assert.equal(getCodexPlanLabel(false, { chatgptPlanType: "Pro" }), ""); +}); + +test("getCodexPlanLabel returns empty string when chatgptPlanType is missing/blank", () => { + assert.equal(getCodexPlanLabel(true, {}), ""); + assert.equal(getCodexPlanLabel(true, { chatgptPlanType: " " }), ""); + assert.equal(getCodexPlanLabel(true, undefined), ""); +}); + +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. + assert.equal(resolvePlanValue("unknown", { chatgptPlanType: "Pro" }), "Pro"); +}); + +test("resolvePlanValue still prefers a real live plan over the persisted Codex fallback", () => { + assert.equal(resolvePlanValue("Team", { chatgptPlanType: "Pro" }), "Team"); +}); + +test("resolvePlanValue returns null when neither live nor persisted Codex plan is available", () => { + assert.equal(resolvePlanValue("unknown", {}), null); + assert.equal(resolvePlanValue(null, null), null); +});