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 <carmelogunsroses@gmail.com>
Inspired-by: https://github.com/decolua/9router/pull/2570
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-14 23:22:13 -03:00
parent fc6063679c
commit 213f6e8851
4 changed files with 84 additions and 1 deletions

View File

@@ -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({
<Badge variant={statusPresentation.statusVariant as any} size="sm" dot>
{statusPresentation.statusLabel}
</Badge>
{codexPlanLabel && (
<Badge variant="primary" size="sm" className="capitalize">
{codexPlanLabel}
</Badge>
)}
{/* 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

View File

@@ -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<string, unknown>)
: {};
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.

View File

@@ -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") {

View File

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