mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
feat(dashboard): free-tier grouping with symbolic link in /providers (#2632)
Integrated into release/v3.8.3
This commit is contained in:
committed by
GitHub
parent
80cd30555e
commit
673b4d5c9c
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
|
||||
171
tests/unit/providers-free-tier-filter.test.ts
Normal file
171
tests/unit/providers-free-tier-filter.test.ts
Normal 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"]);
|
||||
});
|
||||
Reference in New Issue
Block a user