Merge pull request #38588 from BerriAI/litellm_/dark-mode-logo-strategy-7b99f2

feat(ui): make provider logos readable in dark mode
This commit is contained in:
yuneng-jiang 2026-08-28 16:25:11 -07:00 • committed by GitHub
commit 733d0b5af5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 179 additions and 2 deletions

View file

@ -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 {

View file

@ -52,6 +52,45 @@ describe("Logo", () => {
warnSpy.mockRestore();
});
it("leaves the caller's class list untouched for an asset that reads on dark", () => {
render(<Logo src="/ui/assets/logos/slack.svg" label="Slack" className="w-5 h-5 shrink-0" />);
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(<Logo src="/ui/assets/logos/slack.svg" label="Slack" className="w-4 w-5 h-5" />);
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(<Logo src="/ui/assets/logos/github.svg" label="GitHub" className="w-5 h-5" />);
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(<Logo src="/ui/assets/logos/fireworks.svg" label="Fireworks" className="w-5 h-5" />);
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(<Logo src="https://cdn.example.com/github.svg" label="Ext" className="w-5 h-5" />);
expect(screen.getByRole("img", { name: "Ext logo" })).toHaveClass("w-5 h-5", { exact: true });
});
it("does not treat a user-supplied logo URL that mimics the bundled asset path", () => {
render(<Logo src="https://cdn.example.com/assets/logos/github.svg" label="Ext" className="w-5 h-5" />);
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(<Logo provider="openrouter" className="w-5 h-5" />);
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(<Logo src="/ui/assets/logos/broken.svg" label="Agent" />);

View file

@ -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<Record<LogoTreatment, string>> = {
invert: "dark:[filter:brightness(0)_invert(1)]",
plate: "dark:bg-logo-surface dark:object-contain dark:p-0.5",
};
export const Logo: React.FC<LogoProps> = ({ provider, src, label, className = "w-4 h-4" }) => {
const [erroredSrc, setErroredSrc] = useState<string | null>(null);
const resolvedSrc = provider !== undefined ? getProviderLogoAndName(provider).logo : resolveLogoSrc(src) ?? "";
@ -20,11 +27,13 @@ export const Logo: React.FC<LogoProps> = ({ provider, src, label, className = "w
);
}
const treatment = logoTreatmentFor(resolvedSrc);
return (
<img
src={resolvedSrc}
alt={`${name || "-"} logo`}
className={className}
className={treatment === undefined ? className : cn(className, DARK_TREATMENT_CLASS[treatment])}
onError={() => {
console.warn(`Logo failed to load: ${resolvedSrc}`);
setErroredSrc(resolvedSrc);

View file

@ -3,6 +3,8 @@ import { normalizeRootPath } from "@/lib/http/resolveApiBase";
const EXTERNAL_SRC = /^(https?:|data:|blob:|\/\/)/i;
export const isExternalAssetSrc = (value: string): boolean => EXTERNAL_SRC.test(value);
/**
* Prefix a root-relative asset path (e.g. "/ui/assets/logos/openai.svg") with the
* proxy's server root path so it resolves when the UI is mounted under a sub-path
@ -22,7 +24,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 (isExternalAssetSrc(value)) return value;
if (value.includes("/_next/static/")) return value;
const prefix = normalizeRootPath(root);
if (prefix && (value === prefix || value.startsWith(`${prefix}/`))) return value;

View file

@ -0,0 +1,67 @@
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 remote URL that also carries a bundled-looking path", () => {
expect(logoTreatmentFor("https://cdn.example.com/assets/logos/github.svg")).toBeUndefined();
expect(logoTreatmentFor("http://cdn.example.com/_next/static/media/github.abc123.svg")).toBeUndefined();
expect(logoTreatmentFor("//cdn.example.com/assets/logos/github.svg")).toBeUndefined();
expect(logoTreatmentFor("HTTPS://CDN.EXAMPLE.COM/assets/logos/github.svg")).toBeUndefined();
});
it("does not treat a data URL that happens to contain a bundled-looking path", () => {
expect(logoTreatmentFor("data:image/svg+xml,/assets/logos/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();
});
});

View file

@ -0,0 +1,58 @@
import { isExternalAssetSrc } from "@/lib/assetPaths";
export type LogoTreatment = "invert" | "plate";
const BUNDLED_LOGO_PATH = /(?:\/assets\/logos\/|\/_next\/static\/media\/)/;
const TREATMENT_BY_ASSET: Readonly<Record<string, LogoTreatment>> = {
"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 || isExternalAssetSrc(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];
};