feat(ui): adopt the new LiteLLM logo and monogram (#43913)
* feat(ui): adopt the new LiteLLM logo and monogram Swap the bundled admin UI logos for the new brand assets: the primary logo in blue for light mode and white for dark mode, and the monogram for the collapsed sidebar, favicons, and the built-in guardrail cards. /get_image gains a variant=monogram query parameter so the collapsed sidebar can request the monogram while admin-configured UI_LOGO_PATH / UI_LOGO_PATH_DARK logos still take precedence. The bundled light logo moves from JPEG to a transparent PNG. * test(ui): query collapsed sidebar logos by role to stay within the lint budget * fix: point remaining logo consumers at the new bundled assets The Rust gateway UI served /get_image from the removed litellm_logo.jpg, the non-root get_image tests pinned logo.jpg, and a cookbook script read litellm/proxy/logo.jpg. Point them at the monogram and logo.png. * fix(mcp): serve the BYOK OAuth page logo from /get_image The page pointed at /ui/assets/logos/litellm_logo.jpg, which the rebrand removes from the dashboard sources, so the next UI build would drop it. /get_image?variant=monogram is always served by the proxy and follows any admin-configured logo. * fix(ui): invert the LiteLLM monogram on dark guardrail cards The blue monogram has a transparent train cut-out, so on a dark card it read as a muddy blue block. Inverting it yields the brand's white mark, which the logo guidelines prescribe for dark backgrounds. * fix(gateway-ui): serve theme and variant aware logos from the dashboard export The Rust gateway served one monogram for every /get_image request, and the committed export lacked it, so /get_image returned 404 until the next UI release build. Pick the full or monogram logo in light or dark from the query, ship those assets in the dashboard's public dir and the committed export, and drop two comments that restated asserted paths.
|
|
@ -12,7 +12,7 @@ def encode_image(image_path):
|
|||
|
||||
|
||||
# Path to your image
|
||||
image_path = "litellm/proxy/logo.jpg"
|
||||
image_path = "litellm/proxy/logo.png"
|
||||
|
||||
# Getting the Base64 string
|
||||
base64_image = encode_image(image_path)
|
||||
|
|
@ -27,7 +27,7 @@ response = client.responses.create(
|
|||
{"type": "input_text", "text": "what color is the image"},
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": f"data:image/jpeg;base64,{base64_image}",
|
||||
"image_url": f"data:image/png;base64,{base64_image}",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ ENV LITELLM_NON_ROOT=true
|
|||
|
||||
RUN mkdir -p /var/lib/litellm/ui /var/lib/litellm/assets && \
|
||||
cp -r /app/litellm/proxy/_experimental/out/. /var/lib/litellm/ui/ && \
|
||||
cp /app/litellm/proxy/logo.jpg /var/lib/litellm/assets/logo.jpg && \
|
||||
cp /app/litellm/proxy/logo.png /var/lib/litellm/assets/logo.png && \
|
||||
touch /var/lib/litellm/ui/.litellm_ui_ready
|
||||
|
||||
RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ license.workspace = true
|
|||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
axum = { workspace = true, features = ["json", "original-uri"] }
|
||||
axum = { workspace = true, features = ["json", "original-uri", "query"] }
|
||||
axum-login.workspace = true
|
||||
base64.workspace = true
|
||||
governor = { version = "0.10.4", default-features = false, features = ["std"] }
|
||||
|
|
@ -16,6 +16,7 @@ rand.workspace = true
|
|||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
time.workspace = true
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
tower-cookies = "0.11.0"
|
||||
tower-http = { version = "0.6.11", features = ["fs", "set-header"] }
|
||||
tower-sessions.workspace = true
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
use std::path::Path;
|
||||
|
||||
use axum::{Router, routing::get};
|
||||
use serde::Serialize;
|
||||
use axum::{
|
||||
Router,
|
||||
extract::{Query, Request},
|
||||
routing::get,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -9,9 +14,39 @@ struct Logo {
|
|||
logo_url: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum Theme {
|
||||
Light,
|
||||
Dark,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum Variant {
|
||||
Full,
|
||||
Monogram,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LogoQuery {
|
||||
theme: Option<Theme>,
|
||||
variant: Option<Variant>,
|
||||
}
|
||||
|
||||
fn logo_file(query: &LogoQuery) -> &'static str {
|
||||
match (query.variant, query.theme) {
|
||||
(Some(Variant::Monogram), Some(Theme::Dark)) => "assets/logos/litellm_monogram_dark.svg",
|
||||
(Some(Variant::Monogram), _) => "assets/logos/litellm_monogram.svg",
|
||||
(_, Some(Theme::Dark)) => "assets/logos/litellm_logo_dark.png",
|
||||
_ => "assets/logos/litellm_logo.png",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dashboard_assets(directory: impl AsRef<Path>) -> Router {
|
||||
let directory = directory.as_ref();
|
||||
let assets = ServeDir::new(directory.join("_next")).append_index_html_on_directories(false);
|
||||
let logos = directory.to_path_buf();
|
||||
|
||||
crate::static_assets(directory)
|
||||
.route(
|
||||
|
|
@ -22,9 +57,11 @@ pub fn dashboard_assets(directory: impl AsRef<Path>) -> Router {
|
|||
})
|
||||
}),
|
||||
)
|
||||
.route_service(
|
||||
.route(
|
||||
"/get_image",
|
||||
ServeFile::new(directory.join("assets/logos/litellm_logo.jpg")),
|
||||
get(move |Query(query): Query<LogoQuery>, request: Request| {
|
||||
ServeFile::new(logos.join(logo_file(&query))).oneshot(request)
|
||||
}),
|
||||
)
|
||||
.route_service(
|
||||
"/get_favicon",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,14 @@ fn dashboard(directory: TempDir) -> App {
|
|||
let export = directory.path().join("public");
|
||||
std::fs::create_dir_all(export.join("_next/static")).unwrap();
|
||||
std::fs::create_dir_all(export.join("assets/logos")).unwrap();
|
||||
std::fs::write(export.join("assets/logos/litellm_logo.jpg"), "logo bytes").unwrap();
|
||||
for (file, bytes) in [
|
||||
("litellm_logo.png", "logo bytes"),
|
||||
("litellm_logo_dark.png", "dark logo bytes"),
|
||||
("litellm_monogram.svg", "monogram bytes"),
|
||||
("litellm_monogram_dark.svg", "dark monogram bytes"),
|
||||
] {
|
||||
std::fs::write(export.join("assets/logos").join(file), bytes).unwrap();
|
||||
}
|
||||
std::fs::write(export.join("favicon.ico"), "icon bytes").unwrap();
|
||||
std::fs::write(export.join("_next/static/app.js"), "window.app = true;").unwrap();
|
||||
App {
|
||||
|
|
@ -130,7 +137,15 @@ async fn missing_paths_never_fall_back_to_dashboard(app: App, #[case] path: &str
|
|||
)]
|
||||
#[case::root_assets("/_next/static/app.js", "window.app = true;", "text/javascript")]
|
||||
#[case::nested_assets("/ui/_next/static/app.js", "window.app = true;", "text/javascript")]
|
||||
#[case::logo("/get_image", "logo bytes", "image/jpeg")]
|
||||
#[case::logo("/get_image", "logo bytes", "image/png")]
|
||||
#[case::logo_light("/get_image?theme=light", "logo bytes", "image/png")]
|
||||
#[case::logo_dark("/get_image?theme=dark", "dark logo bytes", "image/png")]
|
||||
#[case::monogram("/get_image?variant=monogram", "monogram bytes", "image/svg+xml")]
|
||||
#[case::monogram_dark(
|
||||
"/get_image?theme=dark&variant=monogram",
|
||||
"dark monogram bytes",
|
||||
"image/svg+xml"
|
||||
)]
|
||||
#[case::favicon("/get_favicon", "icon bytes", "image/x-icon")]
|
||||
#[tokio::test]
|
||||
async fn dashboard_adapter_preserves_existing_urls(
|
||||
|
|
@ -184,3 +199,19 @@ async fn logo_discovery_points_to_served_image(dashboard: App) {
|
|||
"logo bytes"
|
||||
);
|
||||
}
|
||||
|
||||
#[rstest]
|
||||
#[case::logo("/get_image")]
|
||||
#[case::logo_dark("/get_image?theme=dark")]
|
||||
#[case::monogram("/get_image?variant=monogram")]
|
||||
#[case::monogram_dark("/get_image?theme=dark&variant=monogram")]
|
||||
#[tokio::test]
|
||||
async fn committed_dashboard_export_serves_every_logo(#[case] path: &str) {
|
||||
let export = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../../litellm/proxy/_experimental/out");
|
||||
let response = litellm_gateway_ui::dashboard_assets(export)
|
||||
.oneshot(Request::get(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -506,7 +506,7 @@ def _build_authorize_html(
|
|||
<button class="close-btn" type="button" onclick="doCancel()" title="Close">×</button>
|
||||
|
||||
<div class="logos">
|
||||
<img src="/ui/assets/logos/litellm_logo.jpg" class="logo-img" alt="LiteLLM">
|
||||
<img src="/get_image?variant=monogram" class="logo-img" alt="LiteLLM">
|
||||
<span class="logo-arrow">→</span>
|
||||
<div class="logo logo-s">{server_initial}</div>
|
||||
</div>
|
||||
|
|
|
|||
BIN
litellm/proxy/_experimental/out/assets/logos/litellm_logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 372.14 371.53">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #0117be;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M296.11,254.16c7.79-1.07,7.43-12.43.39-13.28-6.58-.79-14.5.86-21.17-.23l-50.86.37c-7.25,2.25-6.45,12.27,1.1,13.28l70.54-.14Z"/>
|
||||
<path class="cls-1" d="M309.42,196.83c-35.23-15.22-72.84-26.08-109.96-35.87-33.05-8.71-69.92-16.92-103.8-21.32-31.89-4.14-63.43-3.41-95.51-3.23v6.54c36.23-.59,71.45.42,107.34,5.91,48.38,7.41,99.06,19.56,145.78,33.98,27.15,8.38,53.59,18.79,79.2,31.01l.1,67.98c12.46-2,31.2-8.81,39.28-20.3l-.04-24.63c-10.86-17.17-45.21-32.66-62.4-40.09Z"/>
|
||||
<path class="cls-1" d="M14.79,115.72c-.07.12-.14.23-.21.35,23.36.36,46.79,1.59,69.97,4.75,36.98,5.04,75.62,13.61,111.4,24.06,17.99,5.25,35.13,13.22,54.25,12.22,4.13-.22,8.25-1.11,12.36-1.52l-.56-.73c-22.47-16.17-52.91-34.43-91.33-48.5C101.55,81.06,39.38,81.29.08,85.13l.05,31.74c3.47-.28,6.94-.56,10.39-.99,1.4-.02,2.84-.06,4.28-.16Z"/>
|
||||
<path class="cls-1" d="M333.75-.37L37.67.08C16.84.11-.03,17.02,0,37.85l.06,39.13c27.48-4.04,66.25-6.26,112.14,2.78,1.52.3,6.38,1.29,12.73,2.77,54.99,12.83,104.51,41.22,151.11,71.93,22.76,14.99,46.61,30.85,68.19,47.42,8.92,6.85,19.82,15.04,27.57,24.61l-.29-189.2c-.03-20.83-16.94-37.69-37.77-37.66Z"/>
|
||||
<path class="cls-1" d="M92.09,245.59h6.39s0,.03,0,.03l57.07-.09-48.14-66.6c-.53-.87-3.85-2.78-4.61-2.78h-4.75s0,.18,0,.18h-6.06s0-.09,0-.09l-91.76.14.11,69.62,91.76-.14v-.28Z"/>
|
||||
<path class="cls-1" d="M.4,298.26l.05,35.68c.03,20.83,16.94,37.69,37.77,37.66l296.08-.45c20.83-.03,37.69-16.94,37.66-37.77l-.09-59.31c-17.35,19.08-52.22,22.16-75.65,23.75l-295.82.45Z"/>
|
||||
<path class="cls-1" d="M159.06,253.3c-20.19.1-40.38.04-60.58.04v.26s-42.72.07-42.72.07c-.83.12-1.69.18-2.17,0l-53.25.08v9.76s166.32-.25,166.32-.25c-1.47-1.7-5.76-9.61-7.6-9.96Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 372.14 371.53">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M296.11,254.16c7.79-1.07,7.43-12.43.39-13.28-6.58-.79-14.5.86-21.17-.23l-50.86.37c-7.25,2.25-6.45,12.27,1.1,13.28l70.54-.14Z"/>
|
||||
<path class="cls-1" d="M309.42,196.83c-35.23-15.22-72.84-26.08-109.96-35.87-33.05-8.71-69.92-16.92-103.8-21.32-31.89-4.14-63.43-3.41-95.51-3.23v6.54c36.23-.59,71.45.42,107.34,5.91,48.38,7.41,99.06,19.56,145.78,33.98,27.15,8.38,53.59,18.79,79.2,31.01l.1,67.98c12.46-2,31.2-8.81,39.28-20.3l-.04-24.63c-10.86-17.17-45.21-32.66-62.4-40.09Z"/>
|
||||
<path class="cls-1" d="M14.79,115.72c-.07.12-.14.23-.21.35,23.36.36,46.79,1.59,69.97,4.75,36.98,5.04,75.62,13.61,111.4,24.06,17.99,5.25,35.13,13.22,54.25,12.22,4.13-.22,8.25-1.11,12.36-1.52l-.56-.73c-22.47-16.17-52.91-34.43-91.33-48.5C101.55,81.06,39.38,81.29.08,85.13l.05,31.74c3.47-.28,6.94-.56,10.39-.99,1.4-.02,2.84-.06,4.28-.16Z"/>
|
||||
<path class="cls-1" d="M333.75-.37L37.67.08C16.84.11-.03,17.02,0,37.85l.06,39.13c27.48-4.04,66.25-6.26,112.14,2.78,1.52.3,6.38,1.29,12.73,2.77,54.99,12.83,104.51,41.22,151.11,71.93,22.76,14.99,46.61,30.85,68.19,47.42,8.92,6.85,19.82,15.04,27.57,24.61l-.29-189.2c-.03-20.83-16.94-37.69-37.77-37.66Z"/>
|
||||
<path class="cls-1" d="M92.09,245.59h6.39s0,.03,0,.03l57.07-.09-48.14-66.6c-.53-.87-3.85-2.78-4.61-2.78h-4.75s0,.18,0,.18h-6.06s0-.09,0-.09l-91.76.14.11,69.62,91.76-.14v-.28Z"/>
|
||||
<path class="cls-1" d="M.4,298.26l.05,35.68c.03,20.83,16.94,37.69,37.77,37.66l296.08-.45c20.83-.03,37.69-16.94,37.66-37.77l-.09-59.31c-17.35,19.08-52.22,22.16-75.65,23.75l-295.82.45Z"/>
|
||||
<path class="cls-1" d="M159.06,253.3c-20.19.1-40.38.04-60.58.04v.26s-42.72.07-42.72.07c-.83.12-1.69.18-2.17,0l-53.25.08v9.76s166.32-.25,166.32-.25c-1.47-1.7-5.76-9.61-7.6-9.96Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 24 KiB |
BIN
litellm/proxy/logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 17 KiB |
BIN
litellm/proxy/logo_monogram.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
litellm/proxy/logo_monogram_dark.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -17540,13 +17540,17 @@ def _serve_custom_ui_logo(candidate: str) -> Response | None:
|
|||
|
||||
|
||||
@app.get("/get_image", include_in_schema=False)
|
||||
async def get_image(theme: Literal["light", "dark"] | None = None):
|
||||
async def get_image(
|
||||
theme: Literal["light", "dark"] | None = None,
|
||||
variant: Literal["full", "monogram"] = "full",
|
||||
):
|
||||
"""Get logo to show on admin UI"""
|
||||
|
||||
# get current_dir
|
||||
current_dir: Final = os.path.dirname(os.path.abspath(__file__))
|
||||
bundled_light_logo: Final = os.path.join(current_dir, "logo.jpg")
|
||||
bundled_dark_logo: Final = os.path.join(current_dir, "logo_dark.png")
|
||||
bundled_logo_stem: Final = "logo_monogram" if variant == "monogram" else "logo"
|
||||
bundled_light_logo: Final = os.path.join(current_dir, f"{bundled_logo_stem}.png")
|
||||
bundled_dark_logo: Final = os.path.join(current_dir, f"{bundled_logo_stem}_dark.png")
|
||||
default_site_logo: Final = (
|
||||
bundled_dark_logo if theme == "dark" and os.path.isfile(bundled_dark_logo) else bundled_light_logo
|
||||
)
|
||||
|
|
@ -17607,7 +17611,7 @@ async def get_image(theme: Literal["light", "dark"] | None = None):
|
|||
if safe_logo is not None:
|
||||
safe_logo_path, media_type = safe_logo
|
||||
return FileResponse(safe_logo_path, media_type=media_type)
|
||||
return FileResponse(bundled_light_logo, media_type="image/jpeg")
|
||||
return FileResponse(bundled_light_logo, media_type="image/png")
|
||||
|
||||
|
||||
@app.get("/get_favicon", include_in_schema=False)
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 8.7 KiB |
|
|
@ -13,6 +13,7 @@ Covers:
|
|||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
|
@ -218,6 +219,29 @@ def test_authorize_get_returns_html(client):
|
|||
assert "abc123" in resp.text
|
||||
|
||||
|
||||
def test_authorize_page_logo_is_served_by_the_proxy(client):
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
page = client.get(
|
||||
"/v1/mcp/oauth/authorize",
|
||||
params={
|
||||
"client_id": "test-client",
|
||||
"redirect_uri": "http://127.0.0.1:3000/callback",
|
||||
"response_type": "code",
|
||||
"code_challenge": "abc123",
|
||||
"code_challenge_method": "S256",
|
||||
"state": "xyz",
|
||||
"server_id": "my-server",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
logo_src = re.search(r'<img src="([^"]+)" class="logo-img"', page.text).group(1)
|
||||
|
||||
logo = TestClient(app).get(logo_src, follow_redirects=False)
|
||||
|
||||
assert (logo.status_code, logo.headers["content-type"].split(";")[0].startswith("image/")) == (200, True)
|
||||
|
||||
|
||||
def test_authorize_get_missing_redirect_uri(client):
|
||||
resp = client.get(
|
||||
"/v1/mcp/oauth/authorize",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ Routes covered:
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -192,11 +193,19 @@ PNG_IHDR_COLOUR_TYPE_OFFSET = 25
|
|||
PNG_COLOUR_TYPE_RGBA = 6
|
||||
|
||||
|
||||
def test_get_image_dark_theme_returns_logo_with_an_alpha_channel(client, monkeypatch):
|
||||
"""?theme=dark serves the dark logo. It must be an RGBA PNG: the light logo is a
|
||||
JPEG whose baked-in white background renders as a white slab on a dark sidebar."""
|
||||
@pytest.mark.parametrize(
|
||||
"params",
|
||||
[
|
||||
{},
|
||||
{"theme": "dark"},
|
||||
{"variant": "monogram"},
|
||||
{"theme": "dark", "variant": "monogram"},
|
||||
],
|
||||
)
|
||||
def test_get_image_bundled_logos_have_an_alpha_channel(client, monkeypatch, params):
|
||||
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
|
||||
response = client.get("/get_image", params={"theme": "dark"})
|
||||
monkeypatch.delenv("UI_LOGO_PATH_DARK", raising=False)
|
||||
response = client.get("/get_image", params=params)
|
||||
body = response.content
|
||||
shape = {
|
||||
"status": response.status_code,
|
||||
|
|
@ -212,16 +221,33 @@ def test_get_image_dark_theme_returns_logo_with_an_alpha_channel(client, monkeyp
|
|||
}
|
||||
|
||||
|
||||
def test_get_image_without_theme_still_serves_the_light_jpeg(client, monkeypatch):
|
||||
"""The default response is unchanged, so light mode keeps the existing logo."""
|
||||
@pytest.mark.parametrize(
|
||||
("params", "bundled_file"),
|
||||
[
|
||||
({}, "logo.png"),
|
||||
({"theme": "light"}, "logo.png"),
|
||||
({"theme": "dark"}, "logo_dark.png"),
|
||||
({"variant": "monogram"}, "logo_monogram.png"),
|
||||
({"theme": "dark", "variant": "monogram"}, "logo_monogram_dark.png"),
|
||||
],
|
||||
)
|
||||
def test_get_image_serves_the_bundled_logo_for_each_theme_and_variant(client, monkeypatch, params, bundled_file):
|
||||
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
|
||||
response = client.get("/get_image")
|
||||
shape = {
|
||||
"status": response.status_code,
|
||||
"media_type": response.headers.get("content-type", "").split(";")[0],
|
||||
"is_jpeg": response.content[:3] == b"\xff\xd8\xff",
|
||||
}
|
||||
assert shape == {"status": 200, "media_type": "image/jpeg", "is_jpeg": True}
|
||||
monkeypatch.delenv("UI_LOGO_PATH_DARK", raising=False)
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
expected = (Path(proxy_server.__file__).parent / bundled_file).read_bytes()
|
||||
response = client.get("/get_image", params=params)
|
||||
assert (response.status_code, response.content) == (200, expected)
|
||||
|
||||
|
||||
def test_get_image_monogram_variant_keeps_serving_a_custom_ui_logo(client, monkeypatch, tmp_path):
|
||||
custom_logo = tmp_path / "custom.png"
|
||||
custom_logo.write_bytes(PNG_SIGNATURE + b"custom-logo-marker")
|
||||
monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo))
|
||||
response = client.get("/get_image", params={"theme": "dark", "variant": "monogram"})
|
||||
shape = {"status": response.status_code, "body": response.content}
|
||||
assert shape == {"status": 200, "body": PNG_SIGNATURE + b"custom-logo-marker"}
|
||||
|
||||
|
||||
def test_get_image_dark_theme_keeps_serving_a_custom_ui_logo(client, monkeypatch, tmp_path):
|
||||
|
|
@ -290,7 +316,7 @@ def test_get_image_dark_logo_alone_still_serves_the_bundled_light_logo_in_light_
|
|||
"status": response.status_code,
|
||||
"media_type": response.headers.get("content-type", "").split(";")[0],
|
||||
}
|
||||
assert shape == {"status": 200, "media_type": "image/jpeg"}
|
||||
assert shape == {"status": 200, "media_type": "image/png"}
|
||||
|
||||
|
||||
def test_get_image_redirects_remote_url(client, monkeypatch):
|
||||
|
|
|
|||
|
|
@ -6852,7 +6852,6 @@ async def test_get_image_non_root_fallback_to_default_logo(monkeypatch):
|
|||
monkeypatch.setenv("LITELLM_NON_ROOT", "true")
|
||||
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
|
||||
|
||||
# Track path.exists calls to verify it checks /var/lib/litellm/assets/logo.jpg
|
||||
exists_calls = []
|
||||
|
||||
def exists_side_effect(path):
|
||||
|
|
@ -6887,8 +6886,7 @@ async def test_get_image_non_root_fallback_to_default_logo(monkeypatch):
|
|||
# Verify makedirs was called with /var/lib/litellm/assets
|
||||
mock_makedirs.assert_called_once_with("/var/lib/litellm/assets", exist_ok=True)
|
||||
|
||||
# Verify that exists was called to check /var/lib/litellm/assets/logo.jpg
|
||||
assets_logo_path = "/var/lib/litellm/assets/logo.jpg"
|
||||
assets_logo_path = "/var/lib/litellm/assets/logo.png"
|
||||
assert any(assets_logo_path in str(call) for call in exists_calls), f"Should check if {assets_logo_path} exists"
|
||||
|
||||
# Verify FileResponse was called (with fallback logo)
|
||||
|
|
@ -7002,7 +7000,7 @@ async def test_get_image_default_logo_ignores_stale_cache(monkeypatch, tmp_path)
|
|||
assert len(calls_to_file_response) == 1, "FileResponse should be called exactly once"
|
||||
served_path = calls_to_file_response[0]
|
||||
assert served_path != str(cache_path.resolve())
|
||||
assert served_path.endswith("logo.jpg")
|
||||
assert served_path.endswith("/logo.png")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -7034,7 +7032,7 @@ async def test_get_image_custom_logo_missing_falls_through_to_default(monkeypatc
|
|||
assert len(calls_to_file_response) == 1, "FileResponse should be called exactly once"
|
||||
served_path = calls_to_file_response[0]
|
||||
assert served_path != str(custom_logo_path), "Should not attempt to serve a non-existent custom logo"
|
||||
assert served_path.endswith("logo.jpg")
|
||||
assert served_path.endswith("/logo.png")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -7067,7 +7065,7 @@ async def test_get_image_custom_logo_missing_no_cache_serves_default(monkeypatch
|
|||
assert len(calls_to_file_response) == 1, "FileResponse should be called exactly once"
|
||||
served_path = calls_to_file_response[0]
|
||||
assert served_path != str(custom_logo_path), "Should not attempt to serve a non-existent custom logo"
|
||||
assert served_path.endswith("logo.jpg"), f"Expected fallback to default logo.jpg, got {served_path}"
|
||||
assert served_path.endswith("/logo.png"), f"Expected fallback to default logo.png, got {served_path}"
|
||||
|
||||
|
||||
def test_get_config_normalizes_string_callbacks(monkeypatch):
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 9 KiB |
BIN
ui/litellm-dashboard/public/assets/logos/litellm_logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
ui/litellm-dashboard/public/assets/logos/litellm_logo_dark.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 372.14 371.53">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #0117be;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M296.11,254.16c7.79-1.07,7.43-12.43.39-13.28-6.58-.79-14.5.86-21.17-.23l-50.86.37c-7.25,2.25-6.45,12.27,1.1,13.28l70.54-.14Z"/>
|
||||
<path class="cls-1" d="M309.42,196.83c-35.23-15.22-72.84-26.08-109.96-35.87-33.05-8.71-69.92-16.92-103.8-21.32-31.89-4.14-63.43-3.41-95.51-3.23v6.54c36.23-.59,71.45.42,107.34,5.91,48.38,7.41,99.06,19.56,145.78,33.98,27.15,8.38,53.59,18.79,79.2,31.01l.1,67.98c12.46-2,31.2-8.81,39.28-20.3l-.04-24.63c-10.86-17.17-45.21-32.66-62.4-40.09Z"/>
|
||||
<path class="cls-1" d="M14.79,115.72c-.07.12-.14.23-.21.35,23.36.36,46.79,1.59,69.97,4.75,36.98,5.04,75.62,13.61,111.4,24.06,17.99,5.25,35.13,13.22,54.25,12.22,4.13-.22,8.25-1.11,12.36-1.52l-.56-.73c-22.47-16.17-52.91-34.43-91.33-48.5C101.55,81.06,39.38,81.29.08,85.13l.05,31.74c3.47-.28,6.94-.56,10.39-.99,1.4-.02,2.84-.06,4.28-.16Z"/>
|
||||
<path class="cls-1" d="M333.75-.37L37.67.08C16.84.11-.03,17.02,0,37.85l.06,39.13c27.48-4.04,66.25-6.26,112.14,2.78,1.52.3,6.38,1.29,12.73,2.77,54.99,12.83,104.51,41.22,151.11,71.93,22.76,14.99,46.61,30.85,68.19,47.42,8.92,6.85,19.82,15.04,27.57,24.61l-.29-189.2c-.03-20.83-16.94-37.69-37.77-37.66Z"/>
|
||||
<path class="cls-1" d="M92.09,245.59h6.39s0,.03,0,.03l57.07-.09-48.14-66.6c-.53-.87-3.85-2.78-4.61-2.78h-4.75s0,.18,0,.18h-6.06s0-.09,0-.09l-91.76.14.11,69.62,91.76-.14v-.28Z"/>
|
||||
<path class="cls-1" d="M.4,298.26l.05,35.68c.03,20.83,16.94,37.69,37.77,37.66l296.08-.45c20.83-.03,37.69-16.94,37.66-37.77l-.09-59.31c-17.35,19.08-52.22,22.16-75.65,23.75l-295.82.45Z"/>
|
||||
<path class="cls-1" d="M159.06,253.3c-20.19.1-40.38.04-60.58.04v.26s-42.72.07-42.72.07c-.83.12-1.69.18-2.17,0l-53.25.08v9.76s166.32-.25,166.32-.25c-1.47-1.7-5.76-9.61-7.6-9.96Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -0,0 +1,17 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 372.14 371.53">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M296.11,254.16c7.79-1.07,7.43-12.43.39-13.28-6.58-.79-14.5.86-21.17-.23l-50.86.37c-7.25,2.25-6.45,12.27,1.1,13.28l70.54-.14Z"/>
|
||||
<path class="cls-1" d="M309.42,196.83c-35.23-15.22-72.84-26.08-109.96-35.87-33.05-8.71-69.92-16.92-103.8-21.32-31.89-4.14-63.43-3.41-95.51-3.23v6.54c36.23-.59,71.45.42,107.34,5.91,48.38,7.41,99.06,19.56,145.78,33.98,27.15,8.38,53.59,18.79,79.2,31.01l.1,67.98c12.46-2,31.2-8.81,39.28-20.3l-.04-24.63c-10.86-17.17-45.21-32.66-62.4-40.09Z"/>
|
||||
<path class="cls-1" d="M14.79,115.72c-.07.12-.14.23-.21.35,23.36.36,46.79,1.59,69.97,4.75,36.98,5.04,75.62,13.61,111.4,24.06,17.99,5.25,35.13,13.22,54.25,12.22,4.13-.22,8.25-1.11,12.36-1.52l-.56-.73c-22.47-16.17-52.91-34.43-91.33-48.5C101.55,81.06,39.38,81.29.08,85.13l.05,31.74c3.47-.28,6.94-.56,10.39-.99,1.4-.02,2.84-.06,4.28-.16Z"/>
|
||||
<path class="cls-1" d="M333.75-.37L37.67.08C16.84.11-.03,17.02,0,37.85l.06,39.13c27.48-4.04,66.25-6.26,112.14,2.78,1.52.3,6.38,1.29,12.73,2.77,54.99,12.83,104.51,41.22,151.11,71.93,22.76,14.99,46.61,30.85,68.19,47.42,8.92,6.85,19.82,15.04,27.57,24.61l-.29-189.2c-.03-20.83-16.94-37.69-37.77-37.66Z"/>
|
||||
<path class="cls-1" d="M92.09,245.59h6.39s0,.03,0,.03l57.07-.09-48.14-66.6c-.53-.87-3.85-2.78-4.61-2.78h-4.75s0,.18,0,.18h-6.06s0-.09,0-.09l-91.76.14.11,69.62,91.76-.14v-.28Z"/>
|
||||
<path class="cls-1" d="M.4,298.26l.05,35.68c.03,20.83,16.94,37.69,37.77,37.66l296.08-.45c20.83-.03,37.69-16.94,37.66-37.77l-.09-59.31c-17.35,19.08-52.22,22.16-75.65,23.75l-295.82.45Z"/>
|
||||
<path class="cls-1" d="M159.06,253.3c-20.19.1-40.38.04-60.58.04v.26s-42.72.07-42.72.07c-.83.12-1.69.18-2.17,0l-53.25.08v9.76s166.32-.25,166.32-.25c-1.47-1.7-5.76-9.61-7.6-9.96Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -44,7 +44,7 @@ describe("guardrail_garden_data logos", () => {
|
|||
|
||||
it("uses the LiteLLM logo for every content filter card", () => {
|
||||
for (const card of LITELLM_CONTENT_FILTER_CARDS) {
|
||||
expect(card.logo, `card ${card.id}`).toContain("litellm_logo.jpg");
|
||||
expect(card.logo, `card ${card.id}`).toContain("litellm_monogram.svg");
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import guardrailsAiLogo from "../../../../../public/assets/logos/guardrails_ai.j
|
|||
import javelinLogo from "../../../../../public/assets/logos/javelin.png";
|
||||
import lakeraAiLogo from "../../../../../public/assets/logos/lakeraai.jpeg";
|
||||
import lassoLogo from "../../../../../public/assets/logos/lasso.png";
|
||||
import litellmLogo from "../../../../../public/assets/logos/litellm_logo.jpg";
|
||||
import litellmLogo from "../../../../../public/assets/logos/litellm_monogram.svg";
|
||||
import microsoftAzureLogo from "../../../../../public/assets/logos/microsoft_azure.svg";
|
||||
import nomaSecurityLogo from "../../../../../public/assets/logos/noma_security.png";
|
||||
import openaiSmallLogo from "../../../../../public/assets/logos/openai_small.svg";
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 4.4 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../tests/test-utils";
|
||||
import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav";
|
||||
|
|
@ -136,6 +136,37 @@ describe("Sidebar (leftnav)", () => {
|
|||
expect(classesOf(dark).has("dark:block")).toBe(true);
|
||||
});
|
||||
|
||||
const collapsedLogos = () => {
|
||||
const home = within(screen.getByRole("link", { name: /litellm home/i }));
|
||||
return {
|
||||
light: home.getByRole("img", { name: "LiteLLM" }),
|
||||
dark: home.getByRole("presentation", { hidden: true }),
|
||||
};
|
||||
};
|
||||
|
||||
it("requests the bundled monogram for both themes when collapsed", () => {
|
||||
renderWithProviders(<Sidebar collapsed />);
|
||||
|
||||
const { light, dark } = collapsedLogos();
|
||||
|
||||
expect(light).toHaveAttribute("src", expect.stringMatching(/\/get_image\?variant=monogram$/));
|
||||
expect(dark).toHaveAttribute("src", expect.stringMatching(/\/get_image\?theme=dark&variant=monogram$/));
|
||||
});
|
||||
|
||||
it("keeps a configured custom logo when collapsed instead of the LiteLLM monogram", () => {
|
||||
mockUseThemeImpl = () => ({
|
||||
...unbrandedTheme(),
|
||||
logoUrl: "https://cdn.example.com/logo.png",
|
||||
logoUrlDark: "https://cdn.example.com/logo-dark.png",
|
||||
});
|
||||
renderWithProviders(<Sidebar collapsed />);
|
||||
|
||||
const { light, dark } = collapsedLogos();
|
||||
|
||||
expect(light).toHaveAttribute("src", "https://cdn.example.com/logo.png");
|
||||
expect(dark).toHaveAttribute("src", "https://cdn.example.com/logo-dark.png");
|
||||
});
|
||||
|
||||
it("prefers a configured dark logo over the light one in dark mode", () => {
|
||||
mockUseThemeImpl = () => ({
|
||||
...unbrandedTheme(),
|
||||
|
|
|
|||
|
|
@ -84,6 +84,11 @@ const ICON = { strokeWidth: 1.75 } as const;
|
|||
|
||||
const LOGO_CLASS_NAME = "h-7 w-auto max-w-[150px] object-contain group-data-[collapsed=true]/sidebar:w-7";
|
||||
|
||||
function bundledLogoSrc(baseUrl: string, { dark, monogram }: { dark: boolean; monogram: boolean }): string {
|
||||
const query = [dark && "theme=dark", monogram && "variant=monogram"].filter(Boolean).join("&");
|
||||
return `${baseUrl}/get_image${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed?: boolean;
|
||||
onToggleCollapsed?: () => void;
|
||||
|
|
@ -617,9 +622,9 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
const logoSrc = logoUrl || `${baseUrl}/get_image`;
|
||||
const logoSrc = logoUrl || bundledLogoSrc(baseUrl, { dark: false, monogram: collapsed });
|
||||
const reachableDarkLogo = logoUrlDark === erroredDarkLogo ? null : logoUrlDark;
|
||||
const darkLogoSrc = reachableDarkLogo || logoUrl || `${baseUrl}/get_image?theme=dark`;
|
||||
const darkLogoSrc = reachableDarkLogo || logoUrl || bundledLogoSrc(baseUrl, { dark: true, monogram: collapsed });
|
||||
|
||||
return (
|
||||
<Sidebar collapsed={collapsed}>
|
||||
|
|
|
|||
1
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -55907,6 +55907,7 @@ export interface operations {
|
|||
parameters: {
|
||||
query?: {
|
||||
theme?: ("light" | "dark") | null;
|
||||
variant?: "full" | "monogram";
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ describe("logoTreatmentFor", () => {
|
|||
expect(logoTreatmentFor("/ui/assets/logos/github.svg")).toBe("invert");
|
||||
});
|
||||
|
||||
it("inverts the bundled LiteLLM monogram into the white brand mark on dark", () => {
|
||||
expect(logoTreatmentFor("/litellm-asset-prefix/_next/static/media/litellm_monogram.16r2_rdj4dq8r.svg")).toBe(
|
||||
"invert",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks a multicolor dark mark for a plate instead of inversion", () => {
|
||||
expect(logoTreatmentFor("/ui/assets/logos/fireworks.svg")).toBe("plate");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const TREATMENT_BY_ASSET: Readonly<Record<string, LogoTreatment>> = {
|
|||
"lago.svg": "invert",
|
||||
"lambda.svg": "invert",
|
||||
"langflow.svg": "invert",
|
||||
"litellm_monogram.svg": "invert",
|
||||
"lmstudio.svg": "invert",
|
||||
"moonshot.svg": "invert",
|
||||
"nebius.svg": "invert",
|
||||
|
|
|
|||