From 02746eb122bd05c7ef0d47d94a9c5c55cd2b8fa8 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 21 Jul 2026 16:11:38 -0700 Subject: [PATCH] fix(ui): harden provider logo map typing and bundled asset guard (#34163) Follow-up to the static logo import PR. Types providerLogoMap as Partial> so raw string keys and lookups are compile errors, tightens the resolveLogoSrc passthrough from /_next/ to /_next/static/ so lookalike backend paths still get root-prefixed, adds an enum coverage test that locks the exact set of logoless providers, and makes Logo props a discriminated union so provider and src modes cannot be mixed and src mode requires a label. --- .../components/molecules/logo/Logo.test.tsx | 4 +-- .../src/components/molecules/logo/Logo.tsx | 10 +++--- .../components/provider_info_helpers.test.tsx | 36 +++++++++++++++---- .../src/components/provider_info_helpers.tsx | 4 +-- .../src/components/vector_store_providers.tsx | 10 +++--- .../src/lib/assetPaths.test.ts | 10 +++++- ui/litellm-dashboard/src/lib/assetPaths.ts | 2 +- 7 files changed, 52 insertions(+), 24 deletions(-) diff --git a/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx b/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx index 95221f35b8d..c0b52e03a30 100644 --- a/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx @@ -20,8 +20,8 @@ describe("Logo", () => { expect(screen.queryByRole("img")).not.toBeInTheDocument(); }); - it("renders a dash avatar when neither provider nor src is given", () => { - render(); + it("renders a dash avatar when src is empty and the label has no characters", () => { + render(); expect(screen.getByText("-")).toBeInTheDocument(); expect(screen.queryByRole("img")).not.toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx b/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx index 6f18c89ae8e..f5fb1f0805a 100644 --- a/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx @@ -2,12 +2,10 @@ import React, { useState } from "react"; import { getProviderLogoAndName } from "@/components/provider_info_helpers"; import { resolveLogoSrc } from "@/lib/assetPaths"; -interface LogoProps { - provider?: string; - src?: string | null; - label?: string; - className?: string; -} +type LogoProps = { className?: string } & ( + | { provider: string; src?: never; label?: string } + | { provider?: never; src: string | null | undefined; label: string } +); export const Logo: React.FC = ({ provider, src, label, className = "w-4 h-4" }) => { const [erroredSrc, setErroredSrc] = useState(null); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index 966bd4e262c..777cdc62987 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -114,13 +114,35 @@ describe("provider_info_helpers", () => { }); describe("provider logo bundled assets", () => { - it("should expose every provider logo as a truthy bundled URL, never a raw /ui/assets path", () => { - const logos = Object.values(providerLogoMap); - expect(logos.length).toBeGreaterThan(0); - logos.forEach((logo) => { - expect(typeof logo).toBe("string"); - expect(logo.length).toBeGreaterThan(0); - expect(logo.startsWith("/ui/assets/")).toBe(false); + it("should map every provider to a bundled logo except the known logoless set, never a raw /ui/assets path", () => { + const knownLogolessProviders = [ + Providers.AUTO_ROUTER, + Providers.BYTEZ, + Providers.CLARIFAI, + Providers.COMPACTIFAI, + Providers.DATAROBOT, + Providers.DOCKER_MODEL_RUNNER, + Providers.DOTPROMPT, + Providers.EMPOWER, + Providers.GALADRIEL, + Providers.GradientAI, + Providers.HEROKU, + Providers.LEMONADE, + Providers.LLAMAFILE, + Providers.MARITALK, + Providers.NLP_CLOUD, + Providers.NSCALE, + Providers.OVHCLOUD, + Providers.PETALS, + Providers.PG_VECTOR, + Providers.PREDIBASE, + Providers.WANDB, + Providers.ZAI, + ]; + const logolessProviders = Object.values(Providers).filter((provider) => !providerLogoMap[provider]); + expect([...logolessProviders].sort()).toEqual([...knownLogolessProviders].sort()); + Object.values(providerLogoMap).forEach((logo) => { + expect(logo?.startsWith("/ui/assets/")).toBe(false); }); }); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index f8be4511e3a..fa6b3c79230 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -283,7 +283,7 @@ export const provider_map: Record = { const standaloneSubproviderSlugs = new Set(["bedrock_mantle"]); -export const providerLogoMap: Record = { +export const providerLogoMap: Partial> = { [Providers.A2A_Agent]: a2aAgentLogo.src, [Providers.AI21]: ai21Logo.src, [Providers.AI21_CHAT]: ai21Logo.src, @@ -395,7 +395,7 @@ export const getProviderLogoAndName = (providerValue: string): { logo: string; d // Get the display name from Providers enum and logo from map const displayName = Providers[enumKey as keyof typeof Providers]; - const logo = resolveLogoSrc(providerLogoMap[displayName as keyof typeof providerLogoMap]) ?? ""; + const logo = resolveLogoSrc(providerLogoMap[displayName]) ?? ""; return { logo, displayName }; }; diff --git a/ui/litellm-dashboard/src/components/vector_store_providers.tsx b/ui/litellm-dashboard/src/components/vector_store_providers.tsx index 03878d5d6f1..d8ee2eb4720 100644 --- a/ui/litellm-dashboard/src/components/vector_store_providers.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_providers.tsx @@ -26,12 +26,12 @@ export const vectorStoreProviderMap: Record = { }; export const vectorStoreProviderLogoMap: Record = { - [VectorStoreProviders.Bedrock]: providerLogoMap[Providers.Bedrock], + [VectorStoreProviders.Bedrock]: providerLogoMap[Providers.Bedrock] ?? "", [VectorStoreProviders.PgVector]: postgresqlLogo.src, - [VectorStoreProviders.VertexRagEngine]: providerLogoMap[Providers.Vertex_AI], - [VectorStoreProviders.VertexAiSearch]: providerLogoMap[Providers.Vertex_AI], - [VectorStoreProviders.OpenAI]: providerLogoMap[Providers.OpenAI], - [VectorStoreProviders.Azure]: providerLogoMap[Providers.Azure], + [VectorStoreProviders.VertexRagEngine]: providerLogoMap[Providers.Vertex_AI] ?? "", + [VectorStoreProviders.VertexAiSearch]: providerLogoMap[Providers.Vertex_AI] ?? "", + [VectorStoreProviders.OpenAI]: providerLogoMap[Providers.OpenAI] ?? "", + [VectorStoreProviders.Azure]: providerLogoMap[Providers.Azure] ?? "", [VectorStoreProviders.Milvus]: milvusLogo.src, [VectorStoreProviders.S3Vectors]: s3VectorLogo.src, }; diff --git a/ui/litellm-dashboard/src/lib/assetPaths.test.ts b/ui/litellm-dashboard/src/lib/assetPaths.test.ts index 0ca6c055a0a..adadef5d274 100644 --- a/ui/litellm-dashboard/src/lib/assetPaths.test.ts +++ b/ui/litellm-dashboard/src/lib/assetPaths.test.ts @@ -48,7 +48,7 @@ describe("resolveLogoSrc", () => { expect(resolveLogoSrc("//cdn.example.com/x.svg")).toBe("//cdn.example.com/x.svg"); }); - it("passes bundled /_next/ asset URLs through untouched even under a sub-path mount", async () => { + it("passes bundled /_next/static/ asset URLs through untouched even under a sub-path mount", async () => { const { resolveLogoSrc } = await importWithRoot("/litellm"); expect(resolveLogoSrc("/_next/static/media/openai_small.abc123.svg")).toBe( "/_next/static/media/openai_small.abc123.svg", @@ -56,6 +56,14 @@ describe("resolveLogoSrc", () => { expect(resolveLogoSrc("/litellm-asset-prefix/_next/static/media/openai_small.abc123.svg")).toBe( "/litellm-asset-prefix/_next/static/media/openai_small.abc123.svg", ); + expect(resolveLogoSrc("/litellm/_next/static/media/openai_small.abc123.svg")).toBe( + "/litellm/_next/static/media/openai_small.abc123.svg", + ); + }); + + it("still prefixes a backend path that merely contains a /_next/ lookalike segment", async () => { + const { resolveLogoSrc } = await importWithRoot("/litellm"); + expect(resolveLogoSrc("/ui/assets/logos/_next/logo.svg")).toBe("/litellm/ui/assets/logos/_next/logo.svg"); }); it("roots a local asset path using the live server root path", async () => { diff --git a/ui/litellm-dashboard/src/lib/assetPaths.ts b/ui/litellm-dashboard/src/lib/assetPaths.ts index ec359ff2d86..abce69127cb 100644 --- a/ui/litellm-dashboard/src/lib/assetPaths.ts +++ b/ui/litellm-dashboard/src/lib/assetPaths.ts @@ -23,7 +23,7 @@ export const withServerRoot = (path: string, root: string): string => { export const resolveLogoSrc = (value: string | null | undefined, root: string = serverRootPath): string | undefined => { if (!value) return undefined; if (EXTERNAL_SRC.test(value)) return value; - if (value.includes("/_next/")) return value; + if (value.includes("/_next/static/")) return value; const prefix = normalizeRootPath(root); if (prefix && (value === prefix || value.startsWith(`${prefix}/`))) return value; return withServerRoot(value, root);