diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 731457bfb5..df1110b27e 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { Card, CardSkeleton, Badge, Button, CollapsibleSection } from "@/shared/components"; import { AGGREGATOR_PROVIDER_IDS, @@ -9,7 +9,6 @@ import { IDE_PROVIDER_IDS, IMAGE_ONLY_PROVIDER_IDS, VIDEO_PROVIDER_IDS, - isClaudeCodeCompatibleProvider, } from "@/shared/constants/providers"; import { useRouter, useSearchParams } from "next/navigation"; import { getErrorCode, getRelativeTime } from "@/shared/utils"; @@ -19,6 +18,7 @@ import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; import { buildStaticProviderEntries, + buildCompatibleProviderGroups, filterConfiguredProviderEntries, shouldFilterProviderEntriesForDisplayMode, shouldShowFirstProviderHint, @@ -480,37 +480,18 @@ export default function ProvidersPage() { } }; - const compatibleProviders = providerNodes - .filter((node) => node.type === "openai-compatible") - .map((node) => ({ - id: node.id, - name: node.name || t("openaiCompatibleName"), - color: "#10A37F", - textIcon: "OC", - apiType: node.apiType, - })); - - const anthropicCompatibleProviders = providerNodes - .filter( - (node) => node.type === "anthropic-compatible" && !isClaudeCodeCompatibleProvider(node.id) - ) - .map((node) => ({ - id: node.id, - name: node.name || t("anthropicCompatibleName"), - color: "#D97757", - textIcon: "AC", - })); - - const ccCompatibleProviders = providerNodes - .filter( - (node) => node.type === "anthropic-compatible" && isClaudeCodeCompatibleProvider(node.id) - ) - .map((node) => ({ - id: node.id, - name: node.name || ccCompatibleLabel, - color: "#B45309", - textIcon: "CC", - })); + const compatibleProviderGroups = useMemo( + () => + buildCompatibleProviderGroups(providerNodes, { + openaiCompatibleName: t("openaiCompatibleName"), + anthropicCompatibleName: t("anthropicCompatibleName"), + claudeCodeCompatibleName: ccCompatibleLabel, + }), + [ccCompatibleLabel, providerNodes, t] + ); + const compatibleProviders = compatibleProviderGroups.openai; + const anthropicCompatibleProviders = compatibleProviderGroups.anthropic; + const ccCompatibleProviders = compatibleProviderGroups.claudeCode; const effectiveProviderDisplayMode = providerDisplayMode === "configured" && connections.length === 0 ? "all" : providerDisplayMode; @@ -530,7 +511,7 @@ export default function ProvidersPage() { activeServiceKind ); - const blockedProviderSet = new Set(blockedProviders); + const blockedProviderSet = useMemo(() => new Set(blockedProviders), [blockedProviders]); const rawNoAuthEntriesAll = buildStaticProviderEntries("no-auth", getProviderStats); const noAuthEntriesAll = rawNoAuthEntriesAll.filter(({ providerId, provider }) => { const alias = typeof provider.alias === "string" ? provider.alias : null; diff --git a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts index f7a6b4d739..32da435927 100644 --- a/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/providers/providerPageUtils.ts @@ -7,6 +7,7 @@ import { type ResolvedProviderCatalogEntry, type StaticProviderCatalogCategory, } from "@/lib/providers/catalog"; +import { isClaudeCodeCompatibleProvider } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { providerHasServiceKind } from "@/lib/providers/serviceKindIndex"; import { compareTr, matchesSearch } from "@/shared/utils/turkishText"; @@ -25,6 +26,20 @@ export interface ProviderEntry> { toggleAuthType: "oauth" | "free" | "apikey" | "no-auth"; } +export type CompatibleProviderInfo = { + id: string; + name: string; + color: string; + textIcon: string; + apiType?: string; +}; + +export type CompatibleProviderGroups = { + openai: CompatibleProviderInfo[]; + anthropic: CompatibleProviderInfo[]; + claudeCode: CompatibleProviderInfo[]; +}; + export function shouldApplyConfiguredOnlyFilter( showConfiguredOnly: boolean, connectionCount: number @@ -110,6 +125,53 @@ export function buildStaticProviderEntries( ); } +export function buildCompatibleProviderGroups( + providerNodes: Array<{ id: string; name?: string; type?: string; apiType?: string }>, + labels: { + openaiCompatibleName: string; + anthropicCompatibleName: string; + claudeCodeCompatibleName: string; + } +): CompatibleProviderGroups { + const openai: CompatibleProviderInfo[] = []; + const anthropic: CompatibleProviderInfo[] = []; + const claudeCode: CompatibleProviderInfo[] = []; + + for (const node of providerNodes) { + if (node.type === "openai-compatible") { + openai.push({ + id: node.id, + name: node.name || labels.openaiCompatibleName, + color: "#10A37F", + textIcon: "OC", + apiType: node.apiType, + }); + continue; + } + + if (node.type !== "anthropic-compatible") continue; + + if (isClaudeCodeCompatibleProvider(node.id)) { + claudeCode.push({ + id: node.id, + name: node.name || labels.claudeCodeCompatibleName, + color: "#B45309", + textIcon: "CC", + }); + continue; + } + + anthropic.push({ + id: node.id, + name: node.name || labels.anthropicCompatibleName, + color: "#D97757", + textIcon: "AC", + }); + } + + return { openai, anthropic, claudeCode }; +} + export function filterConfiguredProviderEntries( entries: ProviderEntry[], showConfiguredOnly: boolean, diff --git a/tests/unit/providers-page-utils.test.ts b/tests/unit/providers-page-utils.test.ts index 7c088ccccc..9cb86ac610 100644 --- a/tests/unit/providers-page-utils.test.ts +++ b/tests/unit/providers-page-utils.test.ts @@ -1001,3 +1001,51 @@ test("model search filter is case-insensitive and partial-match", () => { ); assert.equal(byPartial.length, 1, "partial model id 'minimax' should match 'minimax-m3'"); }); + +// #4613: buildCompatibleProviderGroups partitions provider nodes into the +// openai-compatible / anthropic-compatible / claude-code-compatible buckets the +// providers page renders. The memoization in page.tsx wraps this pure helper, so +// guarding the partition logic here is the regression that matters (Rule #18). +test("buildCompatibleProviderGroups partitions nodes by type + claude-code prefix", () => { + const labels = { + openaiCompatibleName: "OpenAI-compatible", + anthropicCompatibleName: "Anthropic-compatible", + claudeCodeCompatibleName: "Claude Code-compatible", + }; + + const groups = providerPageUtils.buildCompatibleProviderGroups( + [ + { id: "my-oai", name: "My OAI", type: "openai-compatible", apiType: "responses" }, + { id: "my-anthropic", name: "My Claude", type: "anthropic-compatible" }, + { id: "anthropic-compatible-cc-acme", name: "Acme CC", type: "anthropic-compatible" }, + { id: "ignored-node", name: "Ignored", type: "gemini-cli" }, + // name omitted → falls back to the provided label + { id: "anon-oai", type: "openai-compatible" }, + ], + labels + ); + + assert.deepEqual( + groups.openai.map((p) => p.id), + ["my-oai", "anon-oai"], + "openai-compatible nodes land in the openai bucket" + ); + assert.equal(groups.openai[0].apiType, "responses", "apiType is preserved"); + assert.equal( + groups.openai[1].name, + labels.openaiCompatibleName, + "missing name falls back to the openai-compatible label" + ); + + assert.deepEqual( + groups.anthropic.map((p) => p.id), + ["my-anthropic"], + "plain anthropic-compatible nodes land in the anthropic bucket" + ); + + assert.deepEqual( + groups.claudeCode.map((p) => p.id), + ["anthropic-compatible-cc-acme"], + "anthropic-compatible nodes with the cc- prefix land in the claudeCode bucket" + ); +});