From 97f993013d35b676a7a74ff1a30de23c0b495c5a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:39:46 -0300 Subject: [PATCH] feat(dashboard): show Codex plan label in provider and quota views (#7210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): show Codex plan label in provider and quota views ConnectionRow on the provider-detail page never surfaced the Codex subscription plan captured at OAuth import time (providerSpecificData.chatgptPlanType, src/lib/oauth/services/codexImport.ts) anywhere in the row UI. Added a small pure helper, getCodexPlanLabel, and a Badge in ConnectionRow gated on isCodex. Separately, the quota view's plan-badge machinery (resolvePlanValue / tierByConnection / QuotaCardHeader) already existed for all providers, but its persisted-metadata fallback list omitted chatgptPlanType. When the live Codex usage endpoint has no plan_type/planType field, the usage service reports the literal string "unknown" (open-sse/services/usage/codex.ts), which resolvePlanValue's normalizePlanCandidate() filters out — so the quota badge fell through to "Unknown" instead of the plan captured at login. Added chatgptPlanType to the persisted candidate list. Co-authored-by: Carmelo Campos Inspired-by: https://github.com/decolua/9router/pull/2570 * chore(changelog): fragment for #7210 * fix(dashboard): extract getCodexPlanLabel to unfreeze providerPageHelpers.ts The Fast Quality Gates file-size ratchet froze providerPageHelpers.ts at 1053 lines; adding getCodexPlanLabel inline pushed it to 1067. Move the self-contained helper into its own codexPlanLabel.ts module instead of growing the frozen file, and repoint ConnectionRow.tsx + the regression test at the new location. No behavior change. --------- Co-authored-by: Carmelo Campos --- .../features/7210-codex-plan-labels.md | 1 + .../providers/[id]/codexPlanLabel.ts | 19 +++++++ .../[id]/components/ConnectionRow.tsx | 13 ++++- .../usage/components/ProviderLimits/utils.tsx | 6 +++ tests/unit/codex-plan-label-2570.test.ts | 50 +++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 changelog.d/features/7210-codex-plan-labels.md create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/codexPlanLabel.ts create mode 100644 tests/unit/codex-plan-label-2570.test.ts 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); +});