diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx index 267cfda72e..2f93c1d275 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, + getCodexPlanLabel, + providerText, + ERROR_TYPE_LABELS, +} from "../providerPageHelpers"; // --------------------------------------------------------------------------- // 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/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index 4345d11962..28941716e4 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -735,6 +735,22 @@ export function normalizeCodexLimitPolicy(policy: unknown): { use5h: boolean; us }; } +/** + * 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. + */ +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() : ""; +} + /** * UI adapter around the canonical getCodexRequestDefaults from requestDefaults.ts. * Adds the "medium" fallback for reasoningEffort required by the connection form. 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..e18a423c72 --- /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]/providerPageHelpers"; +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); +});