Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions litellm/proxy/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3628,8 +3628,14 @@ def join_paths(base_path: str, route: str) -> str:
if not route:
return base_path

# Join with single slash
return f"{base_path}/{route}"
# Check if base_path already ends with the route to avoid duplication
if base_path.endswith(f"/{route}"):
final_path = base_path
else:
# Join with single slash
final_path = f"{base_path}/{route}"

return final_path


def get_custom_url(request_base_url: str, route: Optional[str] = None) -> str:
Expand Down
3 changes: 3 additions & 0 deletions tests/test_litellm/proxy/test_custom_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
0, os.path.abspath("../../..")
) # Adds the parent directory to the system path

# Set the SERVER_ROOT_PATH environment variable to match the custom mount path
os.environ["SERVER_ROOT_PATH"] = "/my-custom-path"

from litellm.proxy.proxy_server import app as litellm_app
from litellm.proxy.proxy_server import proxy_startup_event

Expand Down
49 changes: 46 additions & 3 deletions tests/test_litellm/proxy/test_proxy_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

from unittest.mock import MagicMock

from litellm.proxy.utils import get_custom_url
from litellm.proxy.utils import get_custom_url, join_paths


def test_get_custom_url(monkeypatch):
Expand All @@ -25,7 +25,6 @@ def test_get_custom_url(monkeypatch):
assert custom_url == "http://0.0.0.0:4000/litellm/ui/"



def test_proxy_only_error_true_for_llm_route():
proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache())
assert proxy_logging_obj._is_proxy_only_llm_api_error(
Expand Down Expand Up @@ -60,8 +59,8 @@ def test_proxy_only_error_false_for_other_error_type():


def test_get_model_group_info_order():
from litellm.proxy.proxy_server import _get_model_group_info
from litellm import Router
from litellm.proxy.proxy_server import _get_model_group_info

router = Router(
model_list=[
Expand Down Expand Up @@ -89,3 +88,47 @@ def test_get_model_group_info_order():

model_groups = [m.model_group for m in model_list]
assert model_groups == ["openai/tts-1", "openai/gpt-3.5-turbo"]


def test_join_paths_no_duplication():
"""Test that join_paths doesn't duplicate route when base_path already ends with it"""
result = join_paths(
base_path="http://0.0.0.0:4000/my-custom-path/", route="/my-custom-path"
)
assert result == "http://0.0.0.0:4000/my-custom-path"


def test_join_paths_normal_join():
"""Test normal path joining"""
result = join_paths(base_path="http://0.0.0.0:4000", route="/api/v1")
assert result == "http://0.0.0.0:4000/api/v1"


def test_join_paths_with_trailing_slash():
"""Test path joining with trailing slash on base_path"""
result = join_paths(base_path="http://0.0.0.0:4000/", route="api/v1")
assert result == "http://0.0.0.0:4000/api/v1"


def test_join_paths_empty_base():
"""Test path joining with empty base_path"""
result = join_paths(base_path="", route="api/v1")
assert result == "/api/v1"


def test_join_paths_empty_route():
"""Test path joining with empty route"""
result = join_paths(base_path="http://0.0.0.0:4000", route="")
assert result == "http://0.0.0.0:4000"


def test_join_paths_both_empty():
"""Test path joining with both empty"""
result = join_paths(base_path="", route="")
assert result == "/"


def test_join_paths_nested_path():
"""Test path joining with nested paths"""
result = join_paths(base_path="http://0.0.0.0:4000/v1", route="chat/completions")
assert result == "http://0.0.0.0:4000/v1/chat/completions"
26 changes: 19 additions & 7 deletions ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import * as React from "react";
import { useRouter, usePathname } from "next/navigation";
import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "@/utils/roles";
import UsageIndicator from "@/components/usage_indicator";
import { serverRootPath } from "@/components/networking";

const { Sider } = Layout;

Expand All @@ -56,11 +57,22 @@ interface MenuItemCfg {
/**
* Normalizes NEXT_PUBLIC_BASE_URL to either "/" or "/ui/" (always with a trailing slash).
* Supported env values: "" or "ui/".
* Also considers the serverRootPath from the proxy config (e.g., "/my-custom-path").
*/
const getBasePath = () => {
const raw = process.env.NEXT_PUBLIC_BASE_URL ?? "";
const trimmed = raw.replace(/^\/+|\/+$/g, ""); // strip leading/trailing slashes
return trimmed ? `/${trimmed}/` : "/"; // ensure trailing slash
const uiPath = trimmed ? `/${trimmed}/` : "/";

// If serverRootPath is set and not "/", prepend it to the UI path
if (serverRootPath && serverRootPath !== "/") {
// Remove trailing slash from serverRootPath and ensure uiPath has no leading slash for proper joining
const cleanServerRoot = serverRootPath.replace(/\/+$/, "");
const cleanUiPath = uiPath.replace(/^\/+/, "");
return `${cleanServerRoot}/${cleanUiPath}`;
}

return uiPath;
};

/** Map legacy `page` ids to real app routes (relative, no leading slash). */
Expand Down Expand Up @@ -134,12 +146,8 @@ const toHref = (slugOrPath: string) => {
return `${base}${rel}`;
};

const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => {
const router = useRouter();
const pathname = usePathname() || "/";

// ----- Menu config (unchanged labels/icons; same appearance) -----
const menuItems: MenuItemCfg[] = [
// ----- Menu config (unchanged labels/icons; same appearance) -----
const menuItems: MenuItemCfg[] = [
{ key: "1", page: "api-keys", label: "Virtual Keys", icon: <KeyOutlined style={{ fontSize: 18 }} /> },
{
key: "3",
Expand Down Expand Up @@ -291,6 +299,10 @@ const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelect
},
];

const Sidebar2: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => {
const router = useRouter();
const pathname = usePathname() || "/";

// ----- Filter by role without mutating originals -----
const filteredMenuItems = React.useMemo<MenuItemCfg[]>(() => {
return menuItems
Expand Down
16 changes: 6 additions & 10 deletions ui/litellm-dashboard/src/app/onboarding/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,17 @@ export default function Onboarding() {
return;
}
claimOnboardingToken(accessToken, inviteID, userID, formValues.password).then((data) => {
let litellm_dashboard_ui = "/ui/";
litellm_dashboard_ui += "?login=success";

// set cookie "token" to jwtToken
document.cookie = "token=" + jwtToken;
console.log("redirecting to:", litellm_dashboard_ui);


const proxyBaseUrl = getProxyBaseUrl();
console.log("proxyBaseUrl:", proxyBaseUrl);

// Construct the full redirect URL using the proxyBaseUrl which includes the server root path
let redirectUrl = proxyBaseUrl ? `${proxyBaseUrl}/ui/?login=success` : "/ui/?login=success";
console.log("redirecting to:", redirectUrl);

if (proxyBaseUrl) {
window.location.href = proxyBaseUrl + litellm_dashboard_ui;
} else {
window.location.href = litellm_dashboard_ui;
}
window.location.href = redirectUrl;
});

// redirect to login page
Expand Down
Loading
Loading