From 70275be59b87a6010a7cc219b024b2909e7a98c4 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 24 Jul 2026 11:44:29 -0300 Subject: [PATCH] fix(dashboard): show custom provider_nodes providers in the Topology view instead of only AI_PROVIDERS (#8328) (#8357) --- .../fixes/8328-topology-custom-providers.md | 1 + src/app/(dashboard)/home/ProviderTopology.tsx | 6 +- src/shared/utils/providerFallbackColor.ts | 41 ++++++++++ .../8328-topology-custom-providers.test.ts | 76 +++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/8328-topology-custom-providers.md create mode 100644 src/shared/utils/providerFallbackColor.ts create mode 100644 tests/unit/8328-topology-custom-providers.test.ts diff --git a/changelog.d/fixes/8328-topology-custom-providers.md b/changelog.d/fixes/8328-topology-custom-providers.md new file mode 100644 index 0000000000..b8fe294b24 --- /dev/null +++ b/changelog.d/fixes/8328-topology-custom-providers.md @@ -0,0 +1 @@ +- fix(dashboard): show custom provider_nodes providers in the Topology view instead of only AI_PROVIDERS (#8328) diff --git a/src/app/(dashboard)/home/ProviderTopology.tsx b/src/app/(dashboard)/home/ProviderTopology.tsx index 313e2faad9..1b19cd230c 100644 --- a/src/app/(dashboard)/home/ProviderTopology.tsx +++ b/src/app/(dashboard)/home/ProviderTopology.tsx @@ -8,6 +8,7 @@ import ProviderIcon from "@/shared/components/ProviderIcon"; import { FlowCanvas } from "@/shared/components/flow/FlowCanvas"; import { StatusDot } from "@/shared/components/flow/StatusDot"; import { edgeStyle, FLOW_EDGE_COLORS } from "@/shared/components/flow/edgeStyles"; +import { getFallbackProviderColor } from "@/shared/utils/providerFallbackColor"; import { resolveTopologyNodeLabel } from "./topologyLabel"; // Rings: [capacity, rx, ry]. Each successive ring fits ~6 more nodes. @@ -23,9 +24,12 @@ const RINGS: [number, number, number][] = [ type ProviderConfig = { color?: string; name?: string; textIcon?: string }; function getProviderConfig(providerId: string): ProviderConfig { + // Predefined providers keep their registry color/name untouched. Anything else (custom + // openai-compatible-*/anthropic-compatible-* provider_nodes) gets a deterministic, + // per-id fallback color instead of one shared gray — see #8328. return ( (AI_PROVIDERS as Record)[providerId] || { - color: "#6b7280", + color: getFallbackProviderColor(providerId), name: providerId, } ); diff --git a/src/shared/utils/providerFallbackColor.ts b/src/shared/utils/providerFallbackColor.ts new file mode 100644 index 0000000000..c2646d0aa9 --- /dev/null +++ b/src/shared/utils/providerFallbackColor.ts @@ -0,0 +1,41 @@ +/** + * Deterministic fallback color for provider ids that are not present in the static + * AI_PROVIDERS registry — e.g. user-defined `openai-compatible-*` / `anthropic-compatible-*` + * providers backed by the `provider_nodes` table. Without this, every such custom provider + * rendered as the same anonymous gray node in the Topology view, indistinguishable from any + * other custom provider even though its label was already resolved correctly. See #8328. + */ + +// Small palette of visually distinguishable, saturated colors — spread far enough apart in +// hue to stay distinguishable side-by-side on both light and dark topology backgrounds. +export const FALLBACK_COLOR_PALETTE: readonly string[] = [ + "#0ea5e9", // sky + "#a855f7", // purple + "#f59e0b", // amber + "#10b981", // emerald + "#ec4899", // pink + "#6366f1", // indigo + "#14b8a6", // teal + "#f97316", // orange +]; + +/** FNV-1a hash — fast, deterministic, and well-distributed for short id strings. */ +function hashProviderId(value: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i++) { + hash ^= value.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +/** + * Returns a deterministic color for a provider id not present in the static AI_PROVIDERS + * registry: the same id always maps to the same palette entry, and different ids spread + * across the palette so distinct custom providers stay visually distinguishable. + */ +export function getFallbackProviderColor(providerId: string): string { + if (!providerId) return FALLBACK_COLOR_PALETTE[0]; + const index = hashProviderId(providerId) % FALLBACK_COLOR_PALETTE.length; + return FALLBACK_COLOR_PALETTE[index]; +} diff --git a/tests/unit/8328-topology-custom-providers.test.ts b/tests/unit/8328-topology-custom-providers.test.ts new file mode 100644 index 0000000000..99798e947c --- /dev/null +++ b/tests/unit/8328-topology-custom-providers.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +// #8328 — the Topology view's local getProviderConfig() only looked up the static +// AI_PROVIDERS registry; any id not present there (every custom/compatible provider +// backed by the provider_nodes table) fell back to a single hardcoded gray color +// (#6b7280), so every custom provider rendered as the same anonymous gray node — +// visually indistinguishable from any other custom provider — even though presence +// and label resolution were already correct. This guards the fix: unknown ids get a +// deterministic, distinguishable color instead of one shared gray fallback, while +// predefined AI_PROVIDERS entries stay byte-identical. + +const read = (rel: string) => readFileSync(fileURLToPath(new URL(rel, import.meta.url)), "utf8"); + +const providerTopologySrc = read("../../src/app/(dashboard)/home/ProviderTopology.tsx"); + +test("getFallbackProviderColor gives two different custom provider ids distinct colors", async () => { + const { getFallbackProviderColor } = await import( + "../../src/shared/utils/providerFallbackColor.ts" + ); + const colorAlpha = getFallbackProviderColor("openai-compatible-my-custom-alpha-node"); + const colorBeta = getFallbackProviderColor("openai-compatible-my-custom-beta-node"); + assert.notEqual( + colorAlpha, + colorBeta, + "two distinct custom provider ids must not resolve to the same fallback color" + ); +}); + +test("getFallbackProviderColor is deterministic for the same id", async () => { + const { getFallbackProviderColor } = await import( + "../../src/shared/utils/providerFallbackColor.ts" + ); + const first = getFallbackProviderColor("anthropic-compatible-my-node-abc123"); + const second = getFallbackProviderColor("anthropic-compatible-my-node-abc123"); + assert.equal(first, second, "the same provider id must always resolve to the same color"); +}); + +test("getFallbackProviderColor spreads ids across more than one palette entry", async () => { + const { getFallbackProviderColor, FALLBACK_COLOR_PALETTE } = await import( + "../../src/shared/utils/providerFallbackColor.ts" + ); + assert.ok( + FALLBACK_COLOR_PALETTE.length > 1, + "the fallback palette must offer more than one distinguishable color" + ); + const sampleIds = Array.from({ length: 12 }, (_, i) => `openai-compatible-custom-${i}`); + const distinctColors = new Set(sampleIds.map((id) => getFallbackProviderColor(id))); + assert.ok( + distinctColors.size > 1, + "a spread of custom provider ids must not all collapse onto a single color" + ); +}); + +test("ProviderTopology's getProviderConfig no longer hardcodes a single gray fallback", () => { + assert.doesNotMatch( + providerTopologySrc, + /\|\|\s*\{\s*color:\s*"#6b7280"/, + "the unknown-provider branch must no longer fall back to one hardcoded gray color" + ); + assert.match( + providerTopologySrc, + /getFallbackProviderColor/, + "getProviderConfig must resolve unknown provider ids through the deterministic fallback helper" + ); +}); + +test("ProviderTopology's getProviderConfig still resolves predefined AI_PROVIDERS entries first", () => { + assert.match( + providerTopologySrc, + /\(AI_PROVIDERS as Record\)\[providerId\]/, + "a predefined provider id must still resolve from the static registry, unaffected by the fallback" + ); +});