From 2e8403a073695eeec8e9f1018163f8db8e63d967 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 21 Jul 2026 14:27:36 -0700 Subject: [PATCH] fix(ui): bundle provider logos as static imports and unify fallback in Logo component (#34125) * fix(ui): bundle provider logos as static imports and unify fallback in Logo component providerLogoMap values are now content-hashed bundle URLs emitted by static imports instead of /ui/assets/logos/ path strings, so any deployment that serves the app JS also serves the logos: dev server, proxy /ui mount, server_root_path sub-paths, and the split-chart nginx image where the old route 404d in production. A missing file is now a build error instead of a silent runtime 404. resolveLogoSrc passes /_next/ URLs through untouched so bundled values never get double-prefixed with the server root path. The new Logo molecule owns resolution and the letter-avatar fallback and warns with the failing URL on load error; ProviderLogo delegates to it. The three bare img sites in the agents wizard render through Logo, fixing their broken-image bug. Dashscope now uses qwen.png, RunwayML the on-disk runway.png, and the GradientAI entry is removed (no plausible asset exists). soniox.svg and ai21.svg drop a single mismatched intrinsic dimension attribute that Turbopack's import-time image parser rejects. Dead logoSrc lookup in AddModelForm deleted. Vitest resolves image imports to Next's StaticImageData shape via a config plugin so tests exercise the same /_next/ URLs as production. * fix(ui): retry logo load when src changes after an error Track which src errored instead of a boolean so a Logo instance whose source changes in place (agents modal title) attempts the new URL rather than staying on the letter-avatar until remount. --- .../public/assets/logos/ai21.svg | 2 +- .../public/assets/logos/soniox.svg | 2 +- .../_components/add_agent_form.test.tsx | 89 +++++++ .../agents/_components/add_agent_form.tsx | 12 +- .../src/components/add_model/AddModelForm.tsx | 3 +- .../components/molecules/logo/Logo.test.tsx | 73 ++++++ .../src/components/molecules/logo/Logo.tsx | 36 +++ .../molecules/models/ProviderLogo.tsx | 23 +- .../components/provider_info_helpers.test.tsx | 35 ++- .../src/components/provider_info_helpers.tsx | 234 +++++++++++------- .../src/lib/assetPaths.test.ts | 10 + ui/litellm-dashboard/src/lib/assetPaths.ts | 1 + ui/litellm-dashboard/vitest.config.ts | 20 +- 13 files changed, 400 insertions(+), 140 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx create mode 100644 ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx create mode 100644 ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx diff --git a/ui/litellm-dashboard/public/assets/logos/ai21.svg b/ui/litellm-dashboard/public/assets/logos/ai21.svg index 7e62a9517af..3c8c75e6d6f 100644 --- a/ui/litellm-dashboard/public/assets/logos/ai21.svg +++ b/ui/litellm-dashboard/public/assets/logos/ai21.svg @@ -1 +1 @@ -AI21 \ No newline at end of file +AI21 \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/logos/soniox.svg b/ui/litellm-dashboard/public/assets/logos/soniox.svg index 7b7408401c4..7a1376df1a8 100644 --- a/ui/litellm-dashboard/public/assets/logos/soniox.svg +++ b/ui/litellm-dashboard/public/assets/logos/soniox.svg @@ -1 +1 @@ -Soniox +Soniox diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx new file mode 100644 index 00000000000..767e7c2ae5f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import AddAgentForm from "./add_agent_form"; +import * as networking from "@/components/networking"; +import type { AgentCreateInfo } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + createAgentCall: vi.fn(), + getAgentCreateMetadata: vi.fn(), + getAgentsList: vi.fn(), + keyCreateForAgentCall: vi.fn(), + keyListCall: vi.fn(), + keyUpdateCall: vi.fn(), + modelAvailableCall: vi.fn(), +})); + +vi.mock("./agent_card_discovery", () => ({ + default: () =>
, +})); + +vi.mock("./agent_form_fields", () => ({ + default: () =>
, +})); + +const a2aInfo: AgentCreateInfo = { + agent_type: "a2a", + agent_type_display_name: "A2A Agent", + description: "Agent-to-agent protocol", + logo_url: "/ui/assets/logos/a2a_agent.png", + credential_fields: [], + use_a2a_form_fields: true, +}; + +const renderForm = () => + render(); + +describe("AddAgentForm logos", () => { + beforeEach(() => { + vi.mocked(networking.getAgentCreateMetadata).mockReset().mockResolvedValue([a2aInfo]); + vi.mocked(networking.getAgentsList).mockReset().mockResolvedValue({ agents: [] }); + vi.mocked(networking.keyListCall).mockReset().mockResolvedValue({ keys: [] }); + vi.mocked(networking.modelAvailableCall).mockReset().mockResolvedValue({ data: [] }); + }); + + it("renders the modal title and agent type selection logos as images from logo_url", async () => { + renderForm(); + + const titleLogo = await screen.findByAltText("Agent logo"); + expect(titleLogo).toBeInstanceOf(HTMLImageElement); + expect(titleLogo).toHaveAttribute("src", expect.stringContaining("assets/logos/a2a_agent.png")); + + const selectionLogo = await screen.findByAltText("A2A Agent logo"); + expect(selectionLogo).toBeInstanceOf(HTMLImageElement); + expect(selectionLogo).toHaveAttribute("src", expect.stringContaining("assets/logos/a2a_agent.png")); + }); + + it("renders the option logo when the agent type dropdown is opened", async () => { + renderForm(); + + await screen.findByAltText("A2A Agent logo"); + fireEvent.mouseDown(screen.getByRole("combobox")); + + const optionLogos = await screen.findAllByAltText("A2A Agent logo"); + expect(optionLogos.length).toBeGreaterThanOrEqual(2); + optionLogos.forEach((img) => { + expect(img).toHaveAttribute("src", expect.stringContaining("assets/logos/a2a_agent.png")); + }); + }); + + it("swaps a failing logo for a letter avatar and warns with the url", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + renderForm(); + + const titleLogo = await screen.findByAltText("Agent logo"); + const header = screen.getByText("Add New Agent").parentElement!; + fireEvent.error(titleLogo); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("assets/logos/a2a_agent.png")); + expect(screen.queryByAltText("Agent logo")).not.toBeInTheDocument(); + expect(within(header).getByText("A")).toBeInTheDocument(); + + const selectionLogo = screen.getByAltText("A2A Agent logo"); + fireEvent.error(selectionLogo); + expect(screen.queryByAltText("A2A Agent logo")).not.toBeInTheDocument(); + expect(warnSpy).toHaveBeenCalledTimes(2); + warnSpy.mockRestore(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index 8ca2b5afe16..e35388b78da 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from "react"; import { Modal, Form, Select, Input, Steps, Radio, Tag, Divider, Switch, InputNumber, Collapse } from "antd"; import MessageManager from "@/components/molecules/message_manager"; -import { resolveLogoSrc } from "@/lib/assetPaths"; +import { Logo } from "@/components/molecules/logo/Logo"; import { Button } from "@tremor/react"; import { CheckCircleFilled, KeyOutlined, RobotOutlined, AppstoreOutlined, InfoCircleOutlined } from "@ant-design/icons"; import CreatedKeyDisplay from "@/components/shared/CreatedKeyDisplay"; @@ -712,17 +712,13 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok value={info.agent_type} label={
- + {info.agent_type_display_name}
} >
- {info.agent_type_display_name} +
{info.agent_type_display_name}
{info.description &&
{info.description}
} @@ -948,7 +944,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok title={
{selectedLogo && currentStep < 1 && ( - Agent + )}

Add New Agent

diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 73cfdce5263..a99f8048dca 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -10,7 +10,7 @@ import React, { useEffect, useMemo, useState } from "react"; import TeamDropdown from "../common_components/team_dropdown"; import type { Team } from "../key_team_helpers/key_list"; import { type CredentialItem, type ProviderCreateInfo, modelAvailableCall } from "../networking"; -import { Providers, providerLogoMap } from "../provider_info_helpers"; +import { Providers } from "../provider_info_helpers"; import { ProviderLogo } from "../molecules/models/ProviderLogo"; import AdvancedSettings from "./advanced_settings"; import ConditionalPublicModelName from "./conditional_public_model_name"; @@ -181,7 +181,6 @@ const AddModelForm: React.FC = ({ {sortedProviderMetadata.map((providerInfo) => { const displayName = providerInfo.provider_display_name; const providerKey = providerInfo.provider; - const logoSrc = providerLogoMap[displayName] ?? ""; return ( diff --git a/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx b/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx new file mode 100644 index 00000000000..95221f35b8d --- /dev/null +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.test.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { Logo } from "./Logo"; +import { Providers, providerLogoMap } from "@/components/provider_info_helpers"; + +vi.mock("@/lib/serverRootPath", () => ({ serverRootPath: "/litellm" })); + +describe("Logo", () => { + it("renders the bundled logo untouched by the server root path for a known provider", () => { + render(); + const img = screen.getByRole("img", { name: "openai logo" }); + expect(img.getAttribute("src")).toBe(providerLogoMap[Providers.OpenAI]); + expect(img.getAttribute("src")).toContain("openai_small"); + }); + + it("renders a letter avatar and no img for an unknown provider", () => { + render(); + expect(screen.getByText("u")).toBeInTheDocument(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + it("renders a dash avatar when neither provider nor src is given", () => { + render(); + expect(screen.getByText("-")).toBeInTheDocument(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + it("resolves a backend asset path through the server root path in src mode", () => { + render(); + const img = screen.getByRole("img", { name: "GitHub logo" }); + expect(img.getAttribute("src")).toBe("/litellm/ui/assets/logos/github.svg"); + }); + + it("passes an external https URL through untouched in src mode", () => { + render(); + expect(screen.getByRole("img").getAttribute("src")).toBe("https://cdn.example.com/logo.png"); + }); + + it("swaps to the letter avatar and warns with the failing URL on image error", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + render(); + const img = screen.getByRole("img", { name: "GitHub logo" }); + + act(() => { + fireEvent.error(img); + }); + + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByText("G")).toBeInTheDocument(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("/litellm/ui/assets/logos/github.svg")); + warnSpy.mockRestore(); + }); + + it("retries with a new src after a previous src errored", () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { rerender } = render(); + + act(() => { + fireEvent.error(screen.getByRole("img", { name: "Agent logo" })); + }); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + + rerender(); + const img = screen.getByRole("img", { name: "Agent logo" }); + expect(img.getAttribute("src")).toBe("/litellm/ui/assets/logos/github.svg"); + + rerender(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + expect(screen.getByText("A")).toBeInTheDocument(); + warnSpy.mockRestore(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx b/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx new file mode 100644 index 00000000000..6f18c89ae8e --- /dev/null +++ b/ui/litellm-dashboard/src/components/molecules/logo/Logo.tsx @@ -0,0 +1,36 @@ +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; +} + +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) ?? ""; + const name = label ?? provider ?? ""; + + if (erroredSrc === resolvedSrc || !resolvedSrc) { + return ( +
+ {name.charAt(0) || "-"} +
+ ); + } + + return ( + {`${name { + console.warn(`Logo failed to load: ${resolvedSrc}`); + setErroredSrc(resolvedSrc); + }} + /> + ); +}; diff --git a/ui/litellm-dashboard/src/components/molecules/models/ProviderLogo.tsx b/ui/litellm-dashboard/src/components/molecules/models/ProviderLogo.tsx index 4a9da15e333..4bc11126ae4 100644 --- a/ui/litellm-dashboard/src/components/molecules/models/ProviderLogo.tsx +++ b/ui/litellm-dashboard/src/components/molecules/models/ProviderLogo.tsx @@ -1,24 +1,11 @@ -import React, { useState } from "react"; -import { getProviderLogoAndName } from "../../provider_info_helpers"; +import React from "react"; +import { Logo } from "@/components/molecules/logo/Logo"; interface ProviderLogoProps { provider: string; className?: string; } -export const ProviderLogo: React.FC = ({ provider, className = "w-4 h-4" }) => { - const [hasError, setHasError] = useState(false); - const { logo } = getProviderLogoAndName(provider); - - const showFallback = hasError || !logo; - - if (showFallback) { - return ( -
- {provider?.charAt(0) || "-"} -
- ); - } - - return {`${provider} setHasError(true)} />; -}; +export const ProviderLogo: React.FC = ({ provider, className = "w-4 h-4" }) => ( + +); 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 6e19de4a6cc..966bd4e262c 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -113,24 +113,20 @@ describe("provider_info_helpers", () => { }); }); - describe("provider logo asset paths", () => { - // Regression: a relative "../ui/assets/logos/" base resolved to - // "/ui/ui/assets/logos/..." (404) on the public model hub at - // /ui/model_hub_table/, which sits a level below the /ui/ SPA. Root-absolute - // paths resolve correctly at any route depth. - it("should expose every provider logo as a root-absolute /ui path", () => { + 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(logo.startsWith("/ui/assets/logos/")).toBe(true); - expect(logo).not.toContain("../"); + expect(typeof logo).toBe("string"); + expect(logo.length).toBeGreaterThan(0); + expect(logo.startsWith("/ui/assets/")).toBe(false); }); }); - it("should resolve a provider logo to a root-absolute path via getProviderLogoAndName", () => { + it("should resolve a provider to its own bundled logo via getProviderLogoAndName", () => { const { logo } = getProviderLogoAndName("openai"); - expect(logo.startsWith("/ui/assets/logos/")).toBe(true); - expect(logo).not.toContain("../"); + expect(logo).toContain("openai_small"); }); }); @@ -430,20 +426,19 @@ describe("getProviderLogoAndName under a custom server_root_path", () => { vi.doUnmock("@/lib/serverRootPath"); }); - // Regression: under SERVER_ROOT_PATH=/litellm the logo must be requested at - // /litellm/ui/assets/logos/... A bare /ui/... path is served off the root and - // 404s behind the reverse proxy. - it("prefixes the server root path onto the resolved logo", async () => { + it("returns the bundled logo URL untouched under a sub-path mount", async () => { vi.resetModules(); vi.doMock("@/lib/serverRootPath", () => ({ serverRootPath: "/litellm" })); - const { getProviderLogoAndName } = await import("./provider_info_helpers"); - expect(getProviderLogoAndName("openai").logo).toBe("/litellm/ui/assets/logos/openai_small.svg"); + const helpers = await import("./provider_info_helpers"); + const { logo } = helpers.getProviderLogoAndName("openai"); + expect(logo).toBe(helpers.providerLogoMap[helpers.Providers.OpenAI]); + expect(logo.startsWith("/litellm")).toBe(false); }); - it("leaves the logo at /ui/... when mounted at the root", async () => { + it("returns the bundled logo URL untouched at the root mount", async () => { vi.resetModules(); vi.doMock("@/lib/serverRootPath", () => ({ serverRootPath: "/" })); - const { getProviderLogoAndName } = await import("./provider_info_helpers"); - expect(getProviderLogoAndName("openai").logo).toBe("/ui/assets/logos/openai_small.svg"); + const helpers = await import("./provider_info_helpers"); + expect(helpers.getProviderLogoAndName("openai").logo).toBe(helpers.providerLogoMap[helpers.Providers.OpenAI]); }); }); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index c955087b40a..f8be4511e3a 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -1,4 +1,67 @@ import { resolveLogoSrc } from "@/lib/assetPaths"; +import a2aAgentLogo from "../../public/assets/logos/a2a_agent.png"; +import ai21Logo from "../../public/assets/logos/ai21.svg"; +import aimlApiLogo from "../../public/assets/logos/aiml_api.svg"; +import anthropicLogo from "../../public/assets/logos/anthropic.svg"; +import assemblyaiSmallLogo from "../../public/assets/logos/assemblyai_small.png"; +import basetenLogo from "../../public/assets/logos/baseten.svg"; +import bedrockLogo from "../../public/assets/logos/bedrock.svg"; +import cerebrasLogo from "../../public/assets/logos/cerebras.svg"; +import cloudflareLogo from "../../public/assets/logos/cloudflare.svg"; +import cohereLogo from "../../public/assets/logos/cohere.svg"; +import cometapiLogo from "../../public/assets/logos/cometapi.svg"; +import cursorLogo from "../../public/assets/logos/cursor.svg"; +import databricksLogo from "../../public/assets/logos/databricks.svg"; +import deepgramLogo from "../../public/assets/logos/deepgram.png"; +import deepinfraLogo from "../../public/assets/logos/deepinfra.png"; +import deepseekLogo from "../../public/assets/logos/deepseek.svg"; +import elevenlabsLogo from "../../public/assets/logos/elevenlabs.png"; +import falAiLogo from "../../public/assets/logos/fal_ai.jpg"; +import featherlessLogo from "../../public/assets/logos/featherless.svg"; +import fireworksLogo from "../../public/assets/logos/fireworks.svg"; +import friendliLogo from "../../public/assets/logos/friendli.svg"; +import githubCopilotLogo from "../../public/assets/logos/github_copilot.svg"; +import googleLogo from "../../public/assets/logos/google.svg"; +import groqLogo from "../../public/assets/logos/groq.svg"; +import huggingfaceLogo from "../../public/assets/logos/huggingface.svg"; +import hyperbolicLogo from "../../public/assets/logos/hyperbolic.svg"; +import infinityLogo from "../../public/assets/logos/infinity.png"; +import jinaLogo from "../../public/assets/logos/jina.png"; +import lambdaLogo from "../../public/assets/logos/lambda.svg"; +import lmstudioLogo from "../../public/assets/logos/lmstudio.svg"; +import metaLlamaLogo from "../../public/assets/logos/meta_llama.svg"; +import microsoftAzureLogo from "../../public/assets/logos/microsoft_azure.svg"; +import minimaxLogo from "../../public/assets/logos/minimax.svg"; +import mistralLogo from "../../public/assets/logos/mistral.svg"; +import moonshotLogo from "../../public/assets/logos/moonshot.svg"; +import morphLogo from "../../public/assets/logos/morph.svg"; +import nebiusLogo from "../../public/assets/logos/nebius.svg"; +import novitaLogo from "../../public/assets/logos/novita.svg"; +import nvidiaNimLogo from "../../public/assets/logos/nvidia_nim.svg"; +import nvidiaTritonLogo from "../../public/assets/logos/nvidia_triton.png"; +import ollamaLogo from "../../public/assets/logos/ollama.svg"; +import openaiSmallLogo from "../../public/assets/logos/openai_small.svg"; +import openrouterLogo from "../../public/assets/logos/openrouter.svg"; +import oracleLogo from "../../public/assets/logos/oracle.svg"; +import perplexityAiLogo from "../../public/assets/logos/perplexity-ai.svg"; +import qwenLogo from "../../public/assets/logos/qwen.png"; +import recraftLogo from "../../public/assets/logos/recraft.svg"; +import replicateLogo from "../../public/assets/logos/replicate.svg"; +import runwayLogo from "../../public/assets/logos/runway.png"; +import sambanovaLogo from "../../public/assets/logos/sambanova.svg"; +import sapLogo from "../../public/assets/logos/sap.png"; +import snowflakeLogo from "../../public/assets/logos/snowflake.svg"; +import sonioxLogo from "../../public/assets/logos/soniox.svg"; +import togetheraiLogo from "../../public/assets/logos/togetherai.svg"; +import topazLogo from "../../public/assets/logos/topaz.svg"; +import v0Logo from "../../public/assets/logos/v0.svg"; +import vercelLogo from "../../public/assets/logos/vercel.svg"; +import vllmLogo from "../../public/assets/logos/vllm.png"; +import volcengineLogo from "../../public/assets/logos/volcengine.png"; +import voyageLogo from "../../public/assets/logos/voyage.webp"; +import watsonxLogo from "../../public/assets/logos/watsonx.svg"; +import xaiLogo from "../../public/assets/logos/xai.svg"; +import xinferenceLogo from "../../public/assets/logos/xinference.svg"; export enum Providers { A2A_Agent = "A2A Agent", @@ -220,94 +283,91 @@ export const provider_map: Record = { const standaloneSubproviderSlugs = new Set(["bedrock_mantle"]); -const asset_logos_folder = "/ui/assets/logos/"; - export const providerLogoMap: Record = { - [Providers.A2A_Agent]: `${asset_logos_folder}a2a_agent.png`, - [Providers.AI21]: `${asset_logos_folder}ai21.svg`, - [Providers.AI21_CHAT]: `${asset_logos_folder}ai21.svg`, - [Providers.AIML]: `${asset_logos_folder}aiml_api.svg`, - [Providers.AIOHTTP_OPENAI]: `${asset_logos_folder}openai_small.svg`, - [Providers.Anthropic]: `${asset_logos_folder}anthropic.svg`, - [Providers.ANTHROPIC_TEXT]: `${asset_logos_folder}anthropic.svg`, - [Providers.AssemblyAI]: `${asset_logos_folder}assemblyai_small.png`, - [Providers.Azure]: `${asset_logos_folder}microsoft_azure.svg`, - [Providers.Azure_AI_Studio]: `${asset_logos_folder}microsoft_azure.svg`, - [Providers.AZURE_TEXT]: `${asset_logos_folder}microsoft_azure.svg`, - [Providers.BASETEN]: `${asset_logos_folder}baseten.svg`, - [Providers.Bedrock]: `${asset_logos_folder}bedrock.svg`, - [Providers.BedrockMantle]: `${asset_logos_folder}bedrock.svg`, - [Providers.SageMaker]: `${asset_logos_folder}bedrock.svg`, - [Providers.Cerebras]: `${asset_logos_folder}cerebras.svg`, - [Providers.CLOUDFLARE]: `${asset_logos_folder}cloudflare.svg`, - [Providers.CODESTRAL]: `${asset_logos_folder}mistral.svg`, - [Providers.Cohere]: `${asset_logos_folder}cohere.svg`, - [Providers.COHERE_CHAT]: `${asset_logos_folder}cohere.svg`, - [Providers.COMETAPI]: `${asset_logos_folder}cometapi.svg`, - [Providers.Cursor]: `${asset_logos_folder}cursor.svg`, - [Providers.Databricks]: `${asset_logos_folder}databricks.svg`, - [Providers.Dashscope]: `${asset_logos_folder}dashscope.svg`, - [Providers.Deepseek]: `${asset_logos_folder}deepseek.svg`, - [Providers.Deepgram]: `${asset_logos_folder}deepgram.png`, - [Providers.DeepInfra]: `${asset_logos_folder}deepinfra.png`, - [Providers.ElevenLabs]: `${asset_logos_folder}elevenlabs.png`, - [Providers.FalAI]: `${asset_logos_folder}fal_ai.jpg`, - [Providers.FEATHERLESS_AI]: `${asset_logos_folder}featherless.svg`, - [Providers.FireworksAI]: `${asset_logos_folder}fireworks.svg`, - [Providers.FRIENDLIAI]: `${asset_logos_folder}friendli.svg`, - [Providers.GITHUB_COPILOT]: `${asset_logos_folder}github_copilot.svg`, - [Providers.Google_AI_Studio]: `${asset_logos_folder}google.svg`, - [Providers.GradientAI]: `${asset_logos_folder}gradientai.svg`, - [Providers.Groq]: `${asset_logos_folder}groq.svg`, - [Providers.Hosted_Vllm]: `${asset_logos_folder}vllm.png`, - [Providers.HUGGINGFACE]: `${asset_logos_folder}huggingface.svg`, - [Providers.HYPERBOLIC]: `${asset_logos_folder}hyperbolic.svg`, - [Providers.Infinity]: `${asset_logos_folder}infinity.png`, - [Providers.JinaAI]: `${asset_logos_folder}jina.png`, - [Providers.LAMBDA_AI]: `${asset_logos_folder}lambda.svg`, - [Providers.LM_STUDIO]: `${asset_logos_folder}lmstudio.svg`, - [Providers.LLAMA]: `${asset_logos_folder}meta_llama.svg`, - [Providers.MiniMax]: `${asset_logos_folder}minimax.svg`, - [Providers.MistralAI]: `${asset_logos_folder}mistral.svg`, - [Providers.MOONSHOT]: `${asset_logos_folder}moonshot.svg`, - [Providers.MORPH]: `${asset_logos_folder}morph.svg`, - [Providers.NEBIUS]: `${asset_logos_folder}nebius.svg`, - [Providers.NOVITA]: `${asset_logos_folder}novita.svg`, - [Providers.NVIDIA_NIM]: `${asset_logos_folder}nvidia_nim.svg`, - [Providers.Ollama]: `${asset_logos_folder}ollama.svg`, - [Providers.OLLAMA_CHAT]: `${asset_logos_folder}ollama.svg`, - [Providers.OOBABOOGA]: `${asset_logos_folder}openai_small.svg`, - [Providers.OpenAI]: `${asset_logos_folder}openai_small.svg`, - [Providers.OPENAI_LIKE]: `${asset_logos_folder}openai_small.svg`, - [Providers.OpenAI_Text]: `${asset_logos_folder}openai_small.svg`, - [Providers.OpenAI_Text_Compatible]: `${asset_logos_folder}openai_small.svg`, - [Providers.OpenAI_Compatible]: `${asset_logos_folder}openai_small.svg`, - [Providers.Openrouter]: `${asset_logos_folder}openrouter.svg`, - [Providers.Oracle]: `${asset_logos_folder}oracle.svg`, - [Providers.Perplexity]: `${asset_logos_folder}perplexity-ai.svg`, - [Providers.RECRAFT]: `${asset_logos_folder}recraft.svg`, - [Providers.REPLICATE]: `${asset_logos_folder}replicate.svg`, - [Providers.RunwayML]: `${asset_logos_folder}runwayml.png`, - [Providers.SAGEMAKER_LEGACY]: `${asset_logos_folder}bedrock.svg`, - [Providers.Sambanova]: `${asset_logos_folder}sambanova.svg`, - [Providers.SAP]: `${asset_logos_folder}sap.png`, - [Providers.Snowflake]: `${asset_logos_folder}snowflake.svg`, - [Providers.Soniox]: `${asset_logos_folder}soniox.svg`, - [Providers.TEXT_COMPLETION_CODESTRAL]: `${asset_logos_folder}mistral.svg`, - [Providers.TogetherAI]: `${asset_logos_folder}togetherai.svg`, - [Providers.TOPAZ]: `${asset_logos_folder}topaz.svg`, - [Providers.Triton]: `${asset_logos_folder}nvidia_triton.png`, - [Providers.V0]: `${asset_logos_folder}v0.svg`, - [Providers.VERCEL_AI_GATEWAY]: `${asset_logos_folder}vercel.svg`, - [Providers.Vertex_AI]: `${asset_logos_folder}google.svg`, - [Providers.VERTEX_AI_BETA]: `${asset_logos_folder}google.svg`, - [Providers.VLLM]: `${asset_logos_folder}vllm.png`, - [Providers.VolcEngine]: `${asset_logos_folder}volcengine.png`, - [Providers.Voyage]: `${asset_logos_folder}voyage.webp`, - [Providers.WATSONX]: `${asset_logos_folder}watsonx.svg`, - [Providers.WATSONX_TEXT]: `${asset_logos_folder}watsonx.svg`, - [Providers.xAI]: `${asset_logos_folder}xai.svg`, - [Providers.XINFERENCE]: `${asset_logos_folder}xinference.svg`, + [Providers.A2A_Agent]: a2aAgentLogo.src, + [Providers.AI21]: ai21Logo.src, + [Providers.AI21_CHAT]: ai21Logo.src, + [Providers.AIML]: aimlApiLogo.src, + [Providers.AIOHTTP_OPENAI]: openaiSmallLogo.src, + [Providers.Anthropic]: anthropicLogo.src, + [Providers.ANTHROPIC_TEXT]: anthropicLogo.src, + [Providers.AssemblyAI]: assemblyaiSmallLogo.src, + [Providers.Azure]: microsoftAzureLogo.src, + [Providers.Azure_AI_Studio]: microsoftAzureLogo.src, + [Providers.AZURE_TEXT]: microsoftAzureLogo.src, + [Providers.BASETEN]: basetenLogo.src, + [Providers.Bedrock]: bedrockLogo.src, + [Providers.BedrockMantle]: bedrockLogo.src, + [Providers.SageMaker]: bedrockLogo.src, + [Providers.Cerebras]: cerebrasLogo.src, + [Providers.CLOUDFLARE]: cloudflareLogo.src, + [Providers.CODESTRAL]: mistralLogo.src, + [Providers.Cohere]: cohereLogo.src, + [Providers.COHERE_CHAT]: cohereLogo.src, + [Providers.COMETAPI]: cometapiLogo.src, + [Providers.Cursor]: cursorLogo.src, + [Providers.Databricks]: databricksLogo.src, + [Providers.Dashscope]: qwenLogo.src, + [Providers.Deepseek]: deepseekLogo.src, + [Providers.Deepgram]: deepgramLogo.src, + [Providers.DeepInfra]: deepinfraLogo.src, + [Providers.ElevenLabs]: elevenlabsLogo.src, + [Providers.FalAI]: falAiLogo.src, + [Providers.FEATHERLESS_AI]: featherlessLogo.src, + [Providers.FireworksAI]: fireworksLogo.src, + [Providers.FRIENDLIAI]: friendliLogo.src, + [Providers.GITHUB_COPILOT]: githubCopilotLogo.src, + [Providers.Google_AI_Studio]: googleLogo.src, + [Providers.Groq]: groqLogo.src, + [Providers.Hosted_Vllm]: vllmLogo.src, + [Providers.HUGGINGFACE]: huggingfaceLogo.src, + [Providers.HYPERBOLIC]: hyperbolicLogo.src, + [Providers.Infinity]: infinityLogo.src, + [Providers.JinaAI]: jinaLogo.src, + [Providers.LAMBDA_AI]: lambdaLogo.src, + [Providers.LM_STUDIO]: lmstudioLogo.src, + [Providers.LLAMA]: metaLlamaLogo.src, + [Providers.MiniMax]: minimaxLogo.src, + [Providers.MistralAI]: mistralLogo.src, + [Providers.MOONSHOT]: moonshotLogo.src, + [Providers.MORPH]: morphLogo.src, + [Providers.NEBIUS]: nebiusLogo.src, + [Providers.NOVITA]: novitaLogo.src, + [Providers.NVIDIA_NIM]: nvidiaNimLogo.src, + [Providers.Ollama]: ollamaLogo.src, + [Providers.OLLAMA_CHAT]: ollamaLogo.src, + [Providers.OOBABOOGA]: openaiSmallLogo.src, + [Providers.OpenAI]: openaiSmallLogo.src, + [Providers.OPENAI_LIKE]: openaiSmallLogo.src, + [Providers.OpenAI_Text]: openaiSmallLogo.src, + [Providers.OpenAI_Text_Compatible]: openaiSmallLogo.src, + [Providers.OpenAI_Compatible]: openaiSmallLogo.src, + [Providers.Openrouter]: openrouterLogo.src, + [Providers.Oracle]: oracleLogo.src, + [Providers.Perplexity]: perplexityAiLogo.src, + [Providers.RECRAFT]: recraftLogo.src, + [Providers.REPLICATE]: replicateLogo.src, + [Providers.RunwayML]: runwayLogo.src, + [Providers.SAGEMAKER_LEGACY]: bedrockLogo.src, + [Providers.Sambanova]: sambanovaLogo.src, + [Providers.SAP]: sapLogo.src, + [Providers.Snowflake]: snowflakeLogo.src, + [Providers.Soniox]: sonioxLogo.src, + [Providers.TEXT_COMPLETION_CODESTRAL]: mistralLogo.src, + [Providers.TogetherAI]: togetheraiLogo.src, + [Providers.TOPAZ]: topazLogo.src, + [Providers.Triton]: nvidiaTritonLogo.src, + [Providers.V0]: v0Logo.src, + [Providers.VERCEL_AI_GATEWAY]: vercelLogo.src, + [Providers.Vertex_AI]: googleLogo.src, + [Providers.VERTEX_AI_BETA]: googleLogo.src, + [Providers.VLLM]: vllmLogo.src, + [Providers.VolcEngine]: volcengineLogo.src, + [Providers.Voyage]: voyageLogo.src, + [Providers.WATSONX]: watsonxLogo.src, + [Providers.WATSONX_TEXT]: watsonxLogo.src, + [Providers.xAI]: xaiLogo.src, + [Providers.XINFERENCE]: xinferenceLogo.src, }; export const getProviderLogoAndName = (providerValue: string): { logo: string; displayName: string } => { diff --git a/ui/litellm-dashboard/src/lib/assetPaths.test.ts b/ui/litellm-dashboard/src/lib/assetPaths.test.ts index cf1b86f39e3..35b5ca3f907 100644 --- a/ui/litellm-dashboard/src/lib/assetPaths.test.ts +++ b/ui/litellm-dashboard/src/lib/assetPaths.test.ts @@ -48,6 +48,16 @@ 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 () => { + const { resolveLogoSrc } = await importWithRoot("/litellm"); + expect(resolveLogoSrc("/_next/static/media/openai_small.abc123.svg")).toBe( + "/_next/static/media/openai_small.abc123.svg", + ); + expect(resolveLogoSrc("/litellm-asset-prefix/_next/static/media/openai_small.abc123.svg")).toBe( + "/litellm-asset-prefix/_next/static/media/openai_small.abc123.svg", + ); + }); + it("roots a local asset path using the live server root path", async () => { const { resolveLogoSrc } = await importWithRoot("/litellm"); expect(resolveLogoSrc("/ui/assets/logos/github.svg")).toBe("/litellm/ui/assets/logos/github.svg"); diff --git a/ui/litellm-dashboard/src/lib/assetPaths.ts b/ui/litellm-dashboard/src/lib/assetPaths.ts index 83daafbb00e..d59335fd7d0 100644 --- a/ui/litellm-dashboard/src/lib/assetPaths.ts +++ b/ui/litellm-dashboard/src/lib/assetPaths.ts @@ -23,5 +23,6 @@ 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; return withServerRoot(value, root); }; diff --git a/ui/litellm-dashboard/vitest.config.ts b/ui/litellm-dashboard/vitest.config.ts index 44a2487b97c..e1c58a0c2b5 100644 --- a/ui/litellm-dashboard/vitest.config.ts +++ b/ui/litellm-dashboard/vitest.config.ts @@ -1,7 +1,19 @@ -import { defineConfig } from "vitest/config"; +import { defineConfig, type Plugin, type ViteUserConfig } from "vitest/config"; import { resolve } from "path"; -export default defineConfig({ +const staticImageData: Plugin = { + name: "next-static-image-data", + enforce: "pre", + load(id: string) { + const path = id.split("?")[0]; + if (!/\.(svg|png|jpe?g|gif|webp|avif|ico|bmp)$/i.test(path)) return undefined; + const name = path.slice(path.lastIndexOf("/") + 1); + return `export default { src: ${JSON.stringify(`/_next/static/media/${name}`)}, height: 24, width: 24 };`; + }, +}; + +const config: ViteUserConfig = { + plugins: [staticImageData], test: { environment: "jsdom", setupFiles: ["tests/setupTests.ts"], @@ -47,4 +59,6 @@ export default defineConfig({ jsx: "automatic", jsxImportSource: "react", }, -}); +}; + +export default defineConfig(config);