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);
+});