From 673b4d5c9c2b40a00eea847cc234d892bb0cfd4f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 23 May 2026 17:18:48 -0300 Subject: [PATCH] feat(dashboard): free-tier grouping with symbolic link in /providers (#2632) Integrated into release/v3.8.3 --- .../providers/components/CategoryDot.tsx | 24 +++ .../providers/components/ProviderCard.tsx | 16 +- .../(dashboard)/dashboard/providers/page.tsx | 111 +++++++----- src/i18n/messages/en.json | 2 + src/i18n/messages/pt-BR.json | 2 + src/shared/constants/providers.ts | 30 ++- tests/unit/providers-free-tier-filter.test.ts | 171 ++++++++++++++++++ 7 files changed, 302 insertions(+), 54 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/providers/components/CategoryDot.tsx create mode 100644 tests/unit/providers-free-tier-filter.test.ts diff --git a/src/app/(dashboard)/dashboard/providers/components/CategoryDot.tsx b/src/app/(dashboard)/dashboard/providers/components/CategoryDot.tsx new file mode 100644 index 0000000000..215a524cc6 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/CategoryDot.tsx @@ -0,0 +1,24 @@ +import { cn } from "@/shared/utils"; + +interface CategoryDotProps { + color: string; + hasFree?: boolean; + label?: string; + freeLabel?: string; + className?: string; +} + +export function CategoryDot({ + color, + hasFree = false, + label, + freeLabel, + className, +}: CategoryDotProps) { + return ( + + + {hasFree && } + + ); +} diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx index 969fa4d049..554d00e8b2 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx @@ -13,6 +13,8 @@ import { isOpenAICompatibleProvider, } from "@/shared/constants/providers"; +import { CategoryDot } from "./CategoryDot"; + interface ProviderStats { total?: number; connected?: number; @@ -194,16 +196,12 @@ export default function ProviderCard({ )} - - {provider.hasFree === true && authType !== "free" && ( - - )}
{allDisabled ? ( diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 40c9a3727c..ba2bc54fd9 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -42,9 +42,24 @@ import { isCodexGlobalFastServiceTierEnabled, } from "@/lib/providers/codexFastTier"; import AddCompatibleProviderModal from "./components/AddCompatibleProviderModal"; +import { CategoryDot } from "./components/CategoryDot"; import ProviderCard from "./components/ProviderCard"; import ProviderCountBadge from "./components/ProviderCountBadge"; +type DashboardProviderInfo = { + id?: string; + name: string; + color?: string; + apiType?: string; + deprecated?: boolean; + deprecationReason?: string; + hasFree?: boolean; + freeNote?: string; + [key: string]: unknown; +}; + +type DashboardProviderEntry = ProviderEntry; + function countConfigured(entries: ProviderEntry[]) { return { configured: entries.filter((entry) => Number(entry.stats?.total || 0) > 0).length, @@ -52,6 +67,19 @@ function countConfigured(entries: ProviderEntry[]) { }; } +function dedupeProviderEntries(entries: DashboardProviderEntry[]): DashboardProviderEntry[] { + const seen = new Set(); + return entries.filter((entry) => { + if (seen.has(entry.providerId)) return false; + seen.add(entry.providerId); + return true; + }); +} + +function providerEntryHasFree(entry: DashboardProviderEntry): boolean { + return entry.provider.hasFree === true; +} + type ProviderBatchTestResult = { connectionId?: string; connectionName?: string; @@ -137,7 +165,12 @@ export default function ProvidersPage() { const [showFreeOnly, setShowFreeOnly] = useState(false); const [activeCategory, setActiveCategory] = useState(null); const notify = useNotificationStore(); - const showSection = (category: string) => !activeCategory || activeCategory === category; + const hasSearchQuery = searchQuery.trim().length > 0; + const showSection = (category: string) => { + if (showFreeOnly) return category === "free"; + if (hasSearchQuery && !activeCategory) return category !== "free"; + return !activeCategory || activeCategory === category; + }; const t = useTranslations("providers"); const tc = useTranslations("common"); const ccCompatibleLabel = t("ccCompatibleLabel"); @@ -556,38 +589,21 @@ export default function ProvidersPage() { showFreeOnly ); - const FREE_SECTION_IDS = new Set([ - "kiro", - "amazon-q", - "gemini-cli", - "qoder", - "pollinations", - "llm7", - "opencode", - "gemini", - "groq", - "cerebras", - "mistral", - "nvidia", - "openrouter", - "cloudflare-ai", - "together", - "siliconflow", - "deepseek", - "longcat", - "glhf", - "morph", - "bazaarlink", - "uncloseai", - "completions", - "freetheai", - "enally", - "puter", - "blackbox", + const staticProviderEntriesAll = dedupeProviderEntries([ + ...oauthProviderEntriesAll, + ...apiKeyProviderEntriesAll, + ...webCookieProviderEntriesAll, + ...localProviderEntriesAll, + ...searchProviderEntriesAll, + ...audioProviderEntriesAll, + ...cloudAgentProviderEntriesAll, + ...upstreamProxyEntriesAll, + ] as DashboardProviderEntry[]); + const dashboardProviderEntriesAll = dedupeProviderEntries([ + ...staticProviderEntriesAll, + ...compatibleProviderEntriesAll, ]); - const freeSectionEntriesAll = [...oauthProviderEntriesAll, ...apiKeyProviderEntriesAll].filter( - (e) => FREE_SECTION_IDS.has(e.providerId) - ); + const freeSectionEntriesAll = dashboardProviderEntriesAll.filter(providerEntryHasFree); const freeSectionEntries = filterConfiguredProviderEntries( freeSectionEntriesAll, effectiveShowConfiguredOnly, @@ -611,12 +627,7 @@ export default function ProvidersPage() { .filter((e) => e.toggleAuthType === "oauth") .filter((e) => !IDE_PROVIDER_IDS.has(e.providerId)); const summaryStats = { - all: { - configured: - oauthProviderEntriesAll.filter((e) => Number(e.stats?.total || 0) > 0).length + - apiKeyProviderEntriesAll.filter((e) => Number(e.stats?.total || 0) > 0).length, - total: oauthProviderEntriesAll.length + apiKeyProviderEntriesAll.length, - }, + all: countConfigured(dashboardProviderEntriesAll), free: countConfigured(freeSectionEntriesAll), oauth: countConfigured(oauthOnlyEntriesAll), apikey: countConfigured(apiKeyProviderEntriesAll), @@ -728,6 +739,7 @@ export default function ProvidersPage() { color: "bg-green-500", label: tc("free"), stat: summaryStats.free, + title: t("freeAggregated"), }, { key: "oauth", @@ -788,6 +800,7 @@ export default function ProvidersPage() { color: string | null; label: string; stat: { configured: number; total: number }; + title?: string; }> ).map((cat) => { const isActive = @@ -817,9 +830,16 @@ export default function ProvidersPage() { ? "bg-primary text-white border-primary" : "bg-bg-subtle border-border text-text-muted hover:text-text-primary hover:border-primary/30" }`} - title={cat.label} + title={cat.title || cat.label} > - {cat.color && } + {cat.color && ( + + )} {cat.label} {cat.stat.configured} @@ -943,10 +963,15 @@ export default function ProvidersPage() {

{t("freeTierProviders")} - +

-

{t("freeTierProvidersDesc")}

+

{t("freeAggregated")}