diff --git a/ui/litellm-dashboard/src/app/globals.css b/ui/litellm-dashboard/src/app/globals.css index f389fa5df3d..87959ca0139 100644 --- a/ui/litellm-dashboard/src/app/globals.css +++ b/ui/litellm-dashboard/src/app/globals.css @@ -140,6 +140,7 @@ --sidebar-border: oklch(0.928 0.006 264.531); --sidebar-ring: oklch(0.707 0.022 261.325); --neutral-border: #dcddeb; + --logo-surface: oklch(1 0 0); } .dark { @@ -227,6 +228,7 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --color-logo-surface: var(--logo-surface); } @layer base { 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 5e4da208f4e..62ce8e9ee12 100644 --- a/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx @@ -52,6 +52,40 @@ describe("Logo", () => { warnSpy.mockRestore(); }); + it("leaves the caller's class list untouched for an asset that reads on dark", () => { + render(); + expect(screen.getByRole("img", { name: "Slack logo" })).toHaveClass("w-5 h-5 shrink-0", { exact: true }); + }); + + it("passes an untreated logo's classes through verbatim rather than normalizing them", () => { + render(); + expect(screen.getByRole("img", { name: "Slack logo" })).toHaveClass("w-4 w-5 h-5", { exact: true }); + }); + + it("forces a monochrome mark to white on dark without disturbing the caller's classes", () => { + render(); + const img = screen.getByRole("img", { name: "GitHub logo" }); + expect(img).toHaveClass("w-5", "h-5", "dark:[filter:brightness(0)_invert(1)]"); + expect(img).not.toHaveClass("dark:bg-logo-surface"); + }); + + it("plates a multicolor dark mark rather than inverting it", () => { + render(); + const img = screen.getByRole("img", { name: "Fireworks logo" }); + expect(img).toHaveClass("dark:bg-logo-surface", "dark:object-contain", "dark:p-0.5"); + expect(img).not.toHaveClass("dark:[filter:brightness(0)_invert(1)]"); + }); + + it("does not treat an external logo URL that collides with a bundled filename", () => { + render(); + expect(screen.getByRole("img", { name: "Ext logo" })).toHaveClass("w-5 h-5", { exact: true }); + }); + + it("applies the treatment to a provider logo resolved through the bundler", () => { + render(); + expect(screen.getByRole("img", { name: "openrouter logo" })).toHaveClass("dark:[filter:brightness(0)_invert(1)]"); + }); + it("retries with a new src after a previous src errored", () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { rerender } = render(); diff --git a/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx b/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx index d388e3dc07d..66c10f1e222 100644 --- a/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx @@ -1,12 +1,19 @@ import React, { useState } from "react"; import { getProviderLogoAndName } from "@/components/provider_info_helpers"; import { resolveLogoSrc } from "@/lib/assetPaths"; +import { cn } from "@/lib/cva.config"; +import { logoTreatmentFor, type LogoTreatment } from "@/lib/logoTreatments"; type LogoProps = { className?: string } & ( | { provider: string; src?: never; label?: string } | { provider?: never; src: string | null | undefined; label: string } ); +const DARK_TREATMENT_CLASS: Readonly> = { + invert: "dark:[filter:brightness(0)_invert(1)]", + plate: "dark:bg-logo-surface dark:object-contain dark:p-0.5", +}; + export const Logo: React.FC = ({ provider, src, label, className = "w-4 h-4" }) => { const [erroredSrc, setErroredSrc] = useState(null); const resolvedSrc = provider !== undefined ? getProviderLogoAndName(provider).logo : resolveLogoSrc(src) ?? ""; @@ -20,11 +27,13 @@ export const Logo: React.FC = ({ provider, src, label, className = "w ); } + const treatment = logoTreatmentFor(resolvedSrc); + return ( {`${name { console.warn(`Logo failed to load: ${resolvedSrc}`); setErroredSrc(resolvedSrc); diff --git a/ui/litellm-dashboard/src/lib/logoTreatments.test.ts b/ui/litellm-dashboard/src/lib/logoTreatments.test.ts new file mode 100644 index 00000000000..b0a2073be8d --- /dev/null +++ b/ui/litellm-dashboard/src/lib/logoTreatments.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { logoTreatmentFor } from "./logoTreatments"; + +describe("logoTreatmentFor", () => { + it("marks a monochrome transparent mark for inversion", () => { + expect(logoTreatmentFor("/ui/assets/logos/github.svg")).toBe("invert"); + }); + + it("marks a multicolor dark mark for a plate instead of inversion", () => { + expect(logoTreatmentFor("/ui/assets/logos/fireworks.svg")).toBe("plate"); + }); + + it("plates a dark mark with a light knockout, which inversion would flatten away", () => { + expect(logoTreatmentFor("/ui/assets/logos/repelloai.png")).toBe("plate"); + expect(logoTreatmentFor("/ui/assets/logos/aiml_api.svg")).toBe("plate"); + }); + + it("leaves an asset that already reads on dark untreated", () => { + expect(logoTreatmentFor("/ui/assets/logos/slack.svg")).toBeUndefined(); + }); + + it("resolves through a bundler fingerprint in the filename", () => { + expect(logoTreatmentFor("/litellm-asset-prefix/_next/static/media/openrouter.1xk7748-_jixf.svg")).toBe("invert"); + }); + + it("resolves a bundled asset served under a proxy root path", () => { + expect(logoTreatmentFor("/litellm/ui/assets/logos/notion.svg")).toBe("invert"); + }); + + it("ignores a query string and fragment on the asset URL", () => { + expect(logoTreatmentFor("/ui/assets/logos/vercel.svg?v=2#icon")).toBe("invert"); + }); + + it("does not treat an external URL whose filename collides with a bundled asset", () => { + expect(logoTreatmentFor("https://cdn.example.com/github.svg")).toBeUndefined(); + }); + + it("does not treat a non-logo path whose filename collides with a bundled asset", () => { + expect(logoTreatmentFor("/uploads/user/github.svg")).toBeUndefined(); + }); + + it("leaves an opaque dark box untreated, since a plate behind it cannot show through", () => { + expect(logoTreatmentFor("/ui/assets/logos/lakeraai.jpeg")).toBeUndefined(); + }); + + it("returns undefined for empty and nullish input", () => { + expect(logoTreatmentFor(null)).toBeUndefined(); + expect(logoTreatmentFor(undefined)).toBeUndefined(); + expect(logoTreatmentFor("")).toBeUndefined(); + }); + + it("distinguishes assets that share a stem but differ by extension", () => { + expect(logoTreatmentFor("/ui/assets/logos/runway.png")).toBe("invert"); + expect(logoTreatmentFor("/ui/assets/logos/runway.svg")).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/lib/logoTreatments.ts b/ui/litellm-dashboard/src/lib/logoTreatments.ts new file mode 100644 index 00000000000..f8378800046 --- /dev/null +++ b/ui/litellm-dashboard/src/lib/logoTreatments.ts @@ -0,0 +1,56 @@ +export type LogoTreatment = "invert" | "plate"; + +const BUNDLED_LOGO_PATH = /(?:\/assets\/logos\/|\/_next\/static\/media\/)/; + +const TREATMENT_BY_ASSET: Readonly> = { + "baseten.svg": "invert", + "cursor.svg": "invert", + "enkrypt_ai.avif": "invert", + "friendli.svg": "invert", + "github.svg": "invert", + "github_copilot.svg": "invert", + "lago.svg": "invert", + "lambda.svg": "invert", + "langflow.svg": "invert", + "lmstudio.svg": "invert", + "moonshot.svg": "invert", + "nebius.svg": "invert", + "notion.svg": "invert", + "ollama.svg": "invert", + "openrouter.svg": "invert", + "promptguard.svg": "invert", + "recraft.svg": "invert", + "replicate.svg": "invert", + "runway.png": "invert", + "scx_ai.svg": "invert", + "secret_detect.png": "invert", + "topaz.svg": "invert", + "v0.svg": "invert", + "vercel.svg": "invert", + "watsonx.svg": "invert", + "aiml_api.svg": "plate", + "akto.svg": "plate", + "aws.svg": "plate", + "deepkeep.svg": "plate", + "fireworks.svg": "plate", + "llm_guard.png": "plate", + "pangea.png": "plate", + "repelloai.png": "plate", + "sambanova.svg": "plate", + "sentry.svg": "plate", + "valkey.svg": "plate", +}; + +const basenameOf = (src: string): string | undefined => src.split(/[?#]/)[0].split("/").pop() || undefined; + +const withoutBundlerHash = (basename: string): string | undefined => { + const parts = basename.split("."); + return parts.length < 2 ? undefined : `${parts[0]}.${parts[parts.length - 1]}`; +}; + +export const logoTreatmentFor = (src: string | null | undefined): LogoTreatment | undefined => { + if (!src || !BUNDLED_LOGO_PATH.test(src)) return undefined; + const basename = basenameOf(src); + const key = basename === undefined ? undefined : withoutBundlerHash(basename); + return key === undefined ? undefined : TREATMENT_BY_ASSET[key]; +};