feat(dashboard): free-tier grouping with symbolic link in /providers (#2632)

Integrated into release/v3.8.3
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-23 17:18:48 -03:00
committed by GitHub
parent 80cd30555e
commit 673b4d5c9c
7 changed files with 302 additions and 54 deletions

View File

@@ -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 (
<span className={cn("inline-flex items-center gap-0.5 shrink-0", className)}>
<span className={cn("size-2 rounded-full shrink-0", color)} title={label} />
{hasFree && <span className="size-2 rounded-full shrink-0 bg-amber-500" title={freeLabel} />}
</span>
);
}

View File

@@ -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({
</span>
</Badge>
)}
<span
className={`size-2 rounded-full shrink-0 ${DOT_COLORS[authType] || DOT_COLORS.apikey}`}
title={dotLabels[authType] || t("apiKeyLabel")}
<CategoryDot
color={DOT_COLORS[authType] || DOT_COLORS.apikey}
hasFree={provider.hasFree === true}
label={dotLabels[authType] || t("apiKeyLabel")}
freeLabel={t("hasFreeTooltip")}
/>
{provider.hasFree === true && authType !== "free" && (
<span
className="size-2 rounded-full shrink-0 bg-green-500"
title={provider.freeNote || t("freeTierAvailable")}
/>
)}
</h3>
<div className="flex items-center gap-2 text-xs flex-wrap">
{allDisabled ? (

View File

@@ -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<DashboardProviderInfo>;
function countConfigured<T>(entries: ProviderEntry<T>[]) {
return {
configured: entries.filter((entry) => Number(entry.stats?.total || 0) > 0).length,
@@ -52,6 +67,19 @@ function countConfigured<T>(entries: ProviderEntry<T>[]) {
};
}
function dedupeProviderEntries(entries: DashboardProviderEntry[]): DashboardProviderEntry[] {
const seen = new Set<string>();
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<string | null>(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 && <span className={`size-2 rounded-full shrink-0 ${cat.color}`} />}
{cat.color && (
<CategoryDot
color={cat.color}
hasFree={cat.key === "free"}
label={cat.label}
freeLabel={t("hasFreeTooltip")}
/>
)}
<span>{cat.label}</span>
<span className={`text-[11px] ${isActive ? "text-white/80" : "text-text-muted"}`}>
{cat.stat.configured}
@@ -943,10 +963,15 @@ export default function ProvidersPage() {
<div className="flex-1 min-w-0">
<h2 className="text-xl font-semibold flex items-center gap-2">
{t("freeTierProviders")}
<span className="size-2.5 rounded-full bg-green-500" title={t("freeTierLabel")} />
<CategoryDot
color="bg-green-500"
hasFree
label={t("freeTierLabel")}
freeLabel={t("hasFreeTooltip")}
/>
<ProviderCountBadge {...countConfigured(freeSectionEntriesAll)} />
</h2>
<p className="text-sm text-text-muted mt-1">{t("freeTierProvidersDesc")}</p>
<p className="text-sm text-text-muted mt-1">{t("freeAggregated")}</p>
</div>
<button
onClick={() => handleBatchTest("free")}
@@ -970,7 +995,7 @@ export default function ProvidersPage() {
<ProviderCard
key={`free-section-${providerId}`}
providerId={providerId}
provider={{ ...provider, hasFree: false }}
provider={provider}
stats={stats}
authType={toggleAuthType === "free" ? "free" : displayAuthType}
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}

View File

@@ -3280,6 +3280,8 @@
"expiringSoonBadge": "Expiring Soon",
"freeTier": "Free Tier",
"freeTierAvailable": "Free tier available",
"hasFreeTooltip": "Free tier available",
"freeAggregated": "All providers with free tier (also shown in their native category)",
"deprecated": "Deprecated",
"deprecatedProvider": "This provider has been deprecated",
"disabled": "Disabled",

View File

@@ -3277,6 +3277,8 @@
"expiringSoonBadge": "Expirando em Breve",
"freeTier": "Plano gratuito",
"freeTierAvailable": "Nível gratuito disponível",
"hasFreeTooltip": "Tier gratuito disponível",
"freeAggregated": "Todos os providers com tier gratuito (também aparecem na categoria nativa)",
"deprecated": "Depreciado",
"deprecatedProvider": "Este provedor foi depreciado",
"disabled": "Desativado",

View File

@@ -2,7 +2,14 @@
// Free Providers
export const FREE_PROVIDERS = {
qoder: { id: "qoder", alias: "if", name: "Qoder AI", icon: "water_drop", color: "#6366F1" },
qoder: {
id: "qoder",
alias: "if",
name: "Qoder AI",
icon: "water_drop",
color: "#6366F1",
hasFree: true,
},
qwen: {
id: "qwen",
alias: "qw",
@@ -19,10 +26,18 @@ export const FREE_PROVIDERS = {
name: "Gemini CLI",
icon: "terminal",
color: "#4285F4",
hasFree: true,
authHint:
"Uses Gemini CLI OAuth / Cloud Code credentials. Pro models require an eligible Google account or paid plan.",
},
kiro: { id: "kiro", alias: "kr", name: "Kiro AI", icon: "psychology_alt", color: "#FF6B35" },
kiro: {
id: "kiro",
alias: "kr",
name: "Kiro AI",
icon: "psychology_alt",
color: "#FF6B35",
hasFree: true,
},
"amazon-q": {
id: "amazon-q",
alias: "aq",
@@ -31,6 +46,7 @@ export const FREE_PROVIDERS = {
color: "#FF9900",
textIcon: "AQ",
website: "https://aws.amazon.com/q/developer/",
hasFree: true,
authHint:
"Uses the same AWS Builder ID or imported refresh-token flow as Kiro, but keeps Amazon Q connections separate.",
},
@@ -43,6 +59,7 @@ export const FREE_PROVIDERS = {
textIcon: "OC",
website: "https://opencode.ai",
noAuth: true,
hasFree: true,
authHint: "No API key required — uses OpenCode's public free endpoint.",
freeNote:
"No API key required — public OpenCode endpoint with Kimi, GLM, Qwen, MiMo, MiniMax models.",
@@ -848,6 +865,7 @@ export const APIKEY_PROVIDERS = {
color: "#58A6FF",
textIcon: "OC",
website: "https://ollama.com/settings/api-keys",
hasFree: true,
},
huggingface: {
id: "huggingface",
@@ -888,6 +906,7 @@ export const APIKEY_PROVIDERS = {
color: "#4285F4",
textIcon: "VA",
website: "https://cloud.google.com/vertex-ai",
hasFree: true,
authHint: "Provide Service Account JSON or OAuth access_token",
},
"vertex-partner": {
@@ -2227,6 +2246,7 @@ export const LOCAL_PROVIDERS = {
color: "#FF7043",
textIcon: "SD",
website: "https://github.com/AUTOMATIC1111/stable-diffusion-webui",
hasFree: true,
authHint:
"No API key required. Configure the local WebUI base URL (default: http://localhost:7860).",
localDefault: "http://localhost:7860",
@@ -2239,6 +2259,7 @@ export const LOCAL_PROVIDERS = {
color: "#4CAF50",
textIcon: "CF",
website: "https://github.com/comfyanonymous/ComfyUI",
hasFree: true,
authHint:
"No API key required. Configure the local ComfyUI base URL (default: http://localhost:8188).",
localDefault: "http://localhost:8188",
@@ -2265,6 +2286,7 @@ export const SEARCH_PROVIDERS = {
color: "#4285F4",
textIcon: "SP",
website: "https://serper.dev",
hasFree: true,
authHint: "API key from serper.dev dashboard",
},
"brave-search": {
@@ -2275,6 +2297,7 @@ export const SEARCH_PROVIDERS = {
color: "#FB542B",
textIcon: "BR",
website: "https://brave.com/search/api",
hasFree: true,
authHint: "Subscription token from Brave Search API dashboard",
},
"exa-search": {
@@ -2285,6 +2308,7 @@ export const SEARCH_PROVIDERS = {
color: "#1E40AF",
textIcon: "EX",
website: "https://exa.ai",
hasFree: true,
authHint: "API key from dashboard.exa.ai",
},
"tavily-search": {
@@ -2295,6 +2319,7 @@ export const SEARCH_PROVIDERS = {
color: "#5B4FDB",
textIcon: "TV",
website: "https://tavily.com",
hasFree: true,
authHint: "API key from app.tavily.com (format: tvly-...)",
},
"google-pse-search": {
@@ -2345,6 +2370,7 @@ export const SEARCH_PROVIDERS = {
color: "#1A237E",
textIcon: "SX",
website: "https://docs.searxng.org",
hasFree: true,
authHint:
"API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access.",
},

View File

@@ -0,0 +1,171 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getStaticProviderCatalogGroup,
type ProviderCatalogMetadata,
type StaticProviderCatalogCategory,
} from "@/lib/providers/catalog";
const CATALOG_CATEGORIES = [
"free",
"oauth",
"web-cookie",
"local",
"search",
"audio",
"upstream-proxy",
"apikey",
"cloud-agent",
] as const satisfies readonly StaticProviderCatalogCategory[];
type CategoryFilter = "all" | StaticProviderCatalogCategory;
interface ProviderFilterEntry {
providerId: string;
provider: ProviderCatalogMetadata;
category: StaticProviderCatalogCategory;
}
function providerHasFree(entry: ProviderFilterEntry): boolean {
return entry.provider.hasFree === true;
}
function dedupeByProviderId(entries: ProviderFilterEntry[]): ProviderFilterEntry[] {
const seen = new Set<string>();
return entries.filter((entry) => {
if (seen.has(entry.providerId)) return false;
seen.add(entry.providerId);
return true;
});
}
function buildCatalogEntries(): ProviderFilterEntry[] {
return CATALOG_CATEGORIES.flatMap((category) => {
const group = getStaticProviderCatalogGroup(category);
return Object.entries(group.providers).map(([providerId, provider]) => ({
providerId,
provider,
category,
}));
});
}
function filterByCategory(
entries: ProviderFilterEntry[],
category: CategoryFilter
): ProviderFilterEntry[] {
if (category === "all") return dedupeByProviderId(entries);
if (category === "free") return dedupeByProviderId(entries.filter(providerHasFree));
return dedupeByProviderId(entries.filter((entry) => entry.category === category));
}
function searchEntries(entries: ProviderFilterEntry[], query: string): ProviderFilterEntry[] {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return dedupeByProviderId(entries);
return dedupeByProviderId(
entries.filter((entry) => {
const name = entry.provider.name.toLowerCase();
const id = entry.providerId.toLowerCase();
return name.includes(normalizedQuery) || id.includes(normalizedQuery);
})
);
}
function countByCategory(entries: ProviderFilterEntry[]) {
return {
all: filterByCategory(entries, "all").length,
free: filterByCategory(entries, "free").length,
oauth: filterByCategory(entries, "oauth").length,
apikey: filterByCategory(entries, "apikey").length,
webcookie: filterByCategory(entries, "web-cookie").length,
search: filterByCategory(entries, "search").length,
audio: filterByCategory(entries, "audio").length,
local: filterByCategory(entries, "local").length,
upstreamProxy: filterByCategory(entries, "upstream-proxy").length,
cloudAgent: filterByCategory(entries, "cloud-agent").length,
};
}
test('filterByCategory("free") returns every hasFree provider across native categories', () => {
const entries = buildCatalogEntries();
const expectedIds = dedupeByProviderId(entries.filter(providerHasFree)).map(
(entry) => entry.providerId
);
const actualIds = filterByCategory(entries, "free").map((entry) => entry.providerId);
assert.deepEqual(actualIds.sort(), expectedIds.sort());
assert.ok(actualIds.length >= 60);
});
test('filterByCategory("apikey") keeps OpenRouter in API Key and Free', () => {
const entries = buildCatalogEntries();
const apiKeyIds = filterByCategory(entries, "apikey").map((entry) => entry.providerId);
const freeIds = filterByCategory(entries, "free").map((entry) => entry.providerId);
assert.ok(apiKeyIds.includes("openrouter"));
assert.ok(freeIds.includes("openrouter"));
});
test("summaryStats.free counts the aggregate overlap without subtracting native categories", () => {
const entries = buildCatalogEntries();
const summaryStats = countByCategory(entries);
const nativeCategoryTotal =
summaryStats.oauth +
summaryStats.apikey +
summaryStats.webcookie +
summaryStats.search +
summaryStats.audio +
summaryStats.local +
summaryStats.upstreamProxy +
summaryStats.cloudAgent;
assert.ok(summaryStats.free >= 60);
assert.ok(nativeCategoryTotal + summaryStats.free > summaryStats.all);
for (const category of CATALOG_CATEGORIES.filter((item) => item !== "free")) {
const overlapCount = filterByCategory(entries, category).filter(providerHasFree).length;
if (overlapCount > 0) {
assert.ok(summaryStats.free >= overlapCount);
}
}
});
test("search results dedupe symbolic Free entries by provider id", () => {
const entries = buildCatalogEntries();
const entriesWithSymbolicFreeGroup = [...entries, ...filterByCategory(entries, "free")];
const results = searchEntries(entriesWithSymbolicFreeGroup, "open");
const ids = results.map((entry) => entry.providerId);
const uniqueIds = new Set(ids);
assert.equal(ids.length, uniqueIds.size);
assert.equal(ids.filter((id) => id === "openrouter").length, 1);
});
test("providers with hasFree undefined are treated as non-free", () => {
const entries: ProviderFilterEntry[] = [
{
providerId: "paid-only",
provider: {
id: "paid-only",
name: "Paid Only",
color: "#111827",
},
category: "apikey",
},
{
providerId: "free-provider",
provider: {
id: "free-provider",
name: "Free Provider",
color: "#10B981",
hasFree: true,
},
category: "oauth",
},
];
const freeIds = filterByCategory(entries, "free").map((entry) => entry.providerId);
assert.deepEqual(freeIds, ["free-provider"]);
});