From 5f75be5c1c979262901c56b1f5ee727ca4007f5a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 25 May 2026 13:44:49 -0700 Subject: [PATCH 001/146] chore(ci): merge dev branch (#28801) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(proxy): route path-dependent call sites through get_request_route Replace direct ``request.url.path`` reads in auth, ACL, routing, and audit-log decisions with ``get_request_route(request)`` — the helper already added in ``auth/auth_utils.py`` that returns the ASGI ``scope["path"]`` with ``root_path`` stripped. Starlette reconstructs ``url.path`` from the Host header; ``scope["path"]`` is uvicorn's parse of the request line and matches what FastAPI dispatches on, so it's the authoritative route for any decision that should agree with the actual handler. Sites: - _experimental/mcp_server/auth/user_api_key_auth_mcp.py - management_endpoints/mcp_management_endpoints.py - vector_store_endpoints/utils.py - pass_through_endpoints/pass_through_endpoints.py - auth/route_checks.py - litellm_pre_call_utils.py - spend_tracking/spend_management_endpoints.py - common_utils/http_parsing_utils.py - management_helpers/utils.py - health_endpoints/_health_endpoints.py Adds regression tests in tests/proxy_unit_tests/test_proxy_routes.py that construct a Request with scope["path"] set to a benign route and the Host header crafted so url.path would resolve differently; each site's decision is asserted against scope["path"]. * chore(proxy): make get_request_route imports lazy at call sites Move the ``from litellm.proxy.auth.auth_utils import get_request_route`` imports added in the prior commit back to the function bodies that use them. The module-level form participates in a long-standing import cycle through ``auth_utils -> _types -> ...`` and was flagged by CodeQL on the PR; the lazy form matches the pattern the proxy already uses for ``user_api_key_auth`` and related helpers elsewhere in these files. Also drop the ``RouteChecks._is_assistants_api_request`` delegation in ``_get_metadata_variable_name`` introduced in the prior commit — the delegation pulled ``RouteChecks`` into the same cycle, and the call site reuses the resolved route for its other branches, so inlining the substring check is both cycle-free and avoids a redundant second ``get_request_route`` call. Comment in test_proxy_routes.py acknowledges that the two MCP table entries exercise ``get_request_route`` directly rather than the full production handler (which needs ASGI scope + MCP state to invoke). --------- Co-authored-by: shin-berri Co-authored-by: user <70670632+stuxf@users.noreply.github.com> --- .../mcp_server/auth/user_api_key_auth_mcp.py | 14 +- litellm/proxy/auth/auth_utils.py | 13 +- litellm/proxy/auth/route_checks.py | 6 +- .../proxy/common_utils/http_parsing_utils.py | 5 +- .../health_endpoints/_health_endpoints.py | 5 +- litellm/proxy/litellm_pre_call_utils.py | 4 +- .../mcp_management_endpoints.py | 5 +- litellm/proxy/management_helpers/utils.py | 7 +- .../pass_through_endpoints.py | 5 +- .../spend_management_endpoints.py | 5 +- litellm/proxy/vector_store_endpoints/utils.py | 18 ++- tests/proxy_unit_tests/test_proxy_routes.py | 121 +++++++++++++++++- 12 files changed, 184 insertions(+), 24 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 708ec7f1176..70fc2c233e7 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -118,15 +118,19 @@ class MCPRequestHandler: return b"{}" request.body = mock_body # type: ignore + # Inline import — auth_utils participates in a proxy import cycle. + from litellm.proxy.auth.auth_utils import ( # noqa: PLC0415 + get_request_route, + ) + + request_route = get_request_route(request) # Only OAuth metadata routes registered under /.well-known/ are public. - # Match on request.url.path (path-only, exact prefix) so the substring - # cannot be smuggled via query string, hostname, or a deeper URL segment. - if request.url.path.startswith("/.well-known/"): + if request_route.startswith("/.well-known/"): validated_user_api_key_auth = UserAPIKeyAuth() elif ( not litellm_api_key and MCPRequestHandler._target_servers_delegate_auth_to_upstream( # noqa: E501 - path=request.url.path, mcp_servers=mcp_servers + path=request_route, mcp_servers=mcp_servers ) ): # Operator opted this oauth2 server into upstream-delegated auth @@ -174,7 +178,7 @@ class MCPRequestHandler: "401", "403", ) and MCPRequestHandler._target_servers_use_oauth2( - path=request.url.path, mcp_servers=mcp_servers + path=request_route, mcp_servers=mcp_servers ): verbose_logger.debug( "MCP OAuth2: target server is OAuth2-mode, treating " diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index c4dcca764b2..1e87dcaef1c 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -498,9 +498,18 @@ def route_in_additonal_public_routes(current_route: str): def get_request_route(request: Request) -> str: """ - Helper to get the route from the request + Resolve the request route from the ASGI scope, with ``root_path`` stripped. - remove base url from path if set e.g. `/genai/chat/completions` -> `/chat/completions + Prefer this over ``request.url.path`` for any auth, ACL, routing, or + audit-log decision: Starlette reconstructs ``url.path`` by interpolating + the Host header into a URL string and re-parsing with ``urlsplit``, so a + malformed Host (e.g. ``localhost/?x=1``) collapses ``url.path`` to ``"/"`` + while FastAPI continues to dispatch on ``scope["path"]``. ``scope["path"]`` + is uvicorn's parse of the HTTP request line and matches the actual + handler, so it's the authoritative route. + + Also normalizes sub-path deployments by stripping ``scope["root_path"]`` + e.g. ``/genai/chat/completions`` -> ``/chat/completions``. """ try: scope = request.scope diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index b2878ba0ae6..3144c5cd25b 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -627,7 +627,11 @@ class RouteChecks: Returns: bool: True if `thread` or `assistant` is in the request path, False otherwise """ - if "thread" in request.url.path or "assistant" in request.url.path: + # Inline import — auth_utils participates in a proxy import cycle. + from .auth_utils import get_request_route # noqa: PLC0415 + + route = get_request_route(request) + if "thread" in route or "assistant" in route: return True return False diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 2ce3fda6297..678ff289649 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -546,7 +546,10 @@ def _add_vector_store_id_from_path(request_data: dict, request: Request) -> None request_data: The request data dictionary to populate request: The FastAPI Request object """ - path = request.url.path + # Inline import — auth_utils participates in a proxy import cycle. + from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415 + + path = get_request_route(request) vector_store_match = re.search(r"/vector_stores/([^/]+)/", path) if vector_store_match: vector_store_id = vector_store_match.group(1) diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index ff3df11c448..ba3aee75047 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -151,7 +151,10 @@ async def test_endpoint(request: Request): dict: A dictionary containing the route of the request URL. """ # ping the proxy server to check if its healthy - return {"route": request.url.path} + # Inline import — auth_utils participates in a proxy import cycle. + from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415 + + return {"route": get_request_route(request)} @router.get( diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 2b840b5495e..0d27b283c47 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -333,8 +333,10 @@ def _get_metadata_variable_name(request: Request) -> str: For ALL other endpoints we call this "metadata" """ - path = request.url.path + # Inline imports — auth_utils/route_checks participate in a proxy import cycle. + from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415 + path = get_request_route(request) if "thread" in path or "assistant" in path: return "litellm_metadata" diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index e9d9c243e7c..431ff49c7ce 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -1568,6 +1568,9 @@ if MCP_AVAILABLE: from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 global_mcp_server_manager, ) + from litellm.proxy.auth.auth_utils import ( # noqa: PLC0415 + get_request_route, + ) server_id = request.path_params.get("server_id", "") if server_id: @@ -1584,7 +1587,7 @@ if MCP_AVAILABLE: ): # For /token, require PKCE authorization_code; refresh_token # grants must NOT bypass auth (see comment above). - path_lower = (request.url.path or "").rstrip("/").lower() + path_lower = get_request_route(request).rstrip("/").lower() if path_lower.endswith("/token"): body_data = await _read_request_body(request=request) grant_type = (body_data or {}).get("grant_type", "") diff --git a/litellm/proxy/management_helpers/utils.py b/litellm/proxy/management_helpers/utils.py index ab100615b46..495bce2f00e 100644 --- a/litellm/proxy/management_helpers/utils.py +++ b/litellm/proxy/management_helpers/utils.py @@ -460,7 +460,12 @@ async def _emit_management_endpoint_otel_span( http_request: Optional[Request] = kwargs.get("http_request") if http_request is not None: - route = http_request.url.path + # Inline import — auth_utils participates in a proxy import cycle. + from litellm.proxy.auth.auth_utils import ( # noqa: PLC0415 + get_request_route, + ) + + route = get_request_route(http_request) request_body: dict = await _read_request_body(request=http_request) else: route = func.__name__ diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 51dbf5ce890..00eaba09acd 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1307,11 +1307,14 @@ def create_pass_through_route( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), subpath: str = "", # captures sub-paths when include_subpath=True ): + from litellm.proxy.auth.auth_utils import ( # noqa: PLC0415 + get_request_route, + ) from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( InitPassThroughEndpointHelpers, ) - path = request.url.path + path = get_request_route(request) # Parse request data based on content type ( diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index e3019801aae..36beb5e9aba 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1817,7 +1817,10 @@ async def ui_view_spend_logs( # noqa: PLR0915 ) try: - is_v2 = "/spend/logs/v2" in request.url.path + # Inline import — auth_utils participates in a proxy import cycle. + from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415 + + is_v2 = "/spend/logs/v2" in get_request_route(request) formats = ["%Y-%m-%d %H:%M:%S", "%Y-%m-%d"] if is_v2 else ["%Y-%m-%d %H:%M:%S"] def parse_date(date_str: str) -> datetime: diff --git a/litellm/proxy/vector_store_endpoints/utils.py b/litellm/proxy/vector_store_endpoints/utils.py index 657b520b271..1221ccf119f 100644 --- a/litellm/proxy/vector_store_endpoints/utils.py +++ b/litellm/proxy/vector_store_endpoints/utils.py @@ -330,11 +330,16 @@ def is_allowed_to_call_vector_store_endpoint( provider_config.get_vector_store_endpoints_by_type() ) + # Inline import — auth_utils participates in a proxy import cycle. + from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415 + + request_route = get_request_route(request) + # Determine the permission type based on the request permission_type = None for endpoint in provider_vector_store_endpoints["read"]: if request.method == endpoint[0] and _does_endpoint_match( - endpoint[1], request.url.path + endpoint[1], request_route ): permission_type = "read" break @@ -342,7 +347,7 @@ def is_allowed_to_call_vector_store_endpoint( if permission_type is None: for endpoint in provider_vector_store_endpoints["write"]: if request.method == endpoint[0] and _does_endpoint_match( - endpoint[1], request.url.path + endpoint[1], request_route ): permission_type = "write" break @@ -392,10 +397,15 @@ def is_allowed_to_call_vector_store_files_endpoint( provider_config.get_vector_store_file_endpoints_by_type() ) + # Inline import — auth_utils participates in a proxy import cycle. + from litellm.proxy.auth.auth_utils import get_request_route # noqa: PLC0415 + + request_route = get_request_route(request) + permission_type: Optional[str] = None for endpoint in provider_vector_store_endpoints.get("read", ()): if request.method == endpoint[0] and _does_endpoint_match( - endpoint[1], request.url.path + endpoint[1], request_route ): permission_type = "read" break @@ -403,7 +413,7 @@ def is_allowed_to_call_vector_store_files_endpoint( if permission_type is None: for endpoint in provider_vector_store_endpoints.get("write", ()): if request.method == endpoint[0] and _does_endpoint_match( - endpoint[1], request.url.path + endpoint[1], request_route ): permission_type = "write" break diff --git a/tests/proxy_unit_tests/test_proxy_routes.py b/tests/proxy_unit_tests/test_proxy_routes.py index 34123e992c2..db41bd65409 100644 --- a/tests/proxy_unit_tests/test_proxy_routes.py +++ b/tests/proxy_unit_tests/test_proxy_routes.py @@ -217,9 +217,120 @@ def _create_request_with_host_header(path: str, host_header: str) -> Request: ], ) def test_get_request_route_not_bypassed_by_malformed_host(host_header: str): - for protected_path in ["/health", "/user/new", "/key/generate", "/get/internal_user_settings"]: - request = _create_request_with_host_header(path=protected_path, host_header=host_header) - result = get_request_route(request) - assert result == protected_path, ( - f"Host: {host_header!r} caused route {protected_path!r} to resolve as {result!r}" + for protected_path in [ + "/health", + "/user/new", + "/key/generate", + "/get/internal_user_settings", + ]: + request = _create_request_with_host_header( + path=protected_path, host_header=host_header ) + result = get_request_route(request) + assert ( + result == protected_path + ), f"Host: {host_header!r} caused route {protected_path!r} to resolve as {result!r}" + + +# --------------------------------------------------------------------------- +# Regression tests for variant call sites that previously read request.url.path +# (Host-derived) instead of the ASGI scope path. Each test sends a Host header +# crafted to collapse url.path to a substring the call site's decision logic +# would match on, while scope["path"] is the real (unmatching) route. +# --------------------------------------------------------------------------- + +_BYPASS_HOSTS = [ + "localhost/?x=1", + "localhost:4000/?x=1", + "localhost/#test", + "localhost:4000/#test", +] + + +def _is_assistants(req): + return RouteChecks._is_assistants_api_request(req) + + +def _metadata_var_name(req): + from litellm.proxy.litellm_pre_call_utils import _get_metadata_variable_name + + return _get_metadata_variable_name(req) + + +def _vector_store_id_in_path(req): + from litellm.proxy.common_utils.http_parsing_utils import ( + _add_vector_store_id_from_path, + ) + + data: dict = {} + _add_vector_store_id_from_path(request_data=data, request=req) + return "vector_store_id" in data + + +# (label, scope_path, host_suffix_template, predicate, expected) — host_suffix_template +# receives the host_header via %s substitution. The predicate is invoked on a Request +# whose scope["path"] is scope_path and whose Host header is the formatted suffix. +# +# The MCP entries (well_known_mcp_bypass, pkce_token_suffix) call +# get_request_route directly rather than the surrounding production handler +# (MCPRequestHandler.process_mcp_request / _mcp_oauth_user_api_key_auth) — +# those handlers require an ASGI scope plus MCP state to invoke, and the call +# sites do nothing with the path except feed it to this helper. The helper- +# level assertion is the relevant signal. +_CALL_SITES = [ + ("assistants_classification", "/key/generate", "%s/thread", _is_assistants, False), + ( + "metadata_variable_name", + "/chat/completions", + "%s/thread", + _metadata_var_name, + "metadata", + ), + ( + "vector_store_id_extraction", + "/key/generate", + "%s/vector_stores/x/files", + _vector_store_id_in_path, + False, + ), + ( + "well_known_mcp_bypass", + "/mcp/tools/call", + "/.well-known/%s", + lambda r: get_request_route(r).startswith("/.well-known/"), + False, + ), + ( + "pkce_token_suffix", + "/mcp/server-id/token", + "%s", + lambda r: get_request_route(r).rstrip("/").lower().endswith("/token"), + True, + ), + ( + "spend_logs_v2_classification", + "/spend/logs", + "%s/spend/logs/v2", + lambda r: "/spend/logs/v2" in get_request_route(r), + False, + ), + ("health_route_echo", "/test", "%s", lambda r: get_request_route(r), "/test"), +] + + +@pytest.mark.parametrize("host_header", _BYPASS_HOSTS) +@pytest.mark.parametrize( + "label,scope_path,host_suffix_template,predicate,expected", + _CALL_SITES, + ids=[c[0] for c in _CALL_SITES], +) +def test_call_site_uses_scope_path( + label, scope_path, host_suffix_template, predicate, expected, host_header +): + """Each call site that previously read request.url.path must now make its + decision against scope["path"]. The Host header is crafted so url.path + would resolve to a value that flips the decision under the old code.""" + request = _create_request_with_host_header( + path=scope_path, host_header=host_suffix_template % host_header + ) + assert predicate(request) == expected From d98ada8c3f7ff6cad3c9643b965fea118dc5f188 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 25 May 2026 13:48:47 -0700 Subject: [PATCH 002/146] chore(ci): merge dev branch (#28657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): navbar hierarchy + Agent Platform notifications (#27543) * feat(dashboard): refine navbar zones and Agent Platform notice Restructure the admin navbar for production users: clear product vs community vs personal columns with vertical dividers, icon-only Slack/GitHub in a shared chip, and Docs/Blog typography aligned on an 8px rhythm. Add a notifications bell with popover linking to the LiteLLM Agent Platform repo and optional mark-as-read persistence. Promote the account control with initials avatar, single-line display name, and navDisplayName mapping for placeholder user ids (e.g. default_user_id). Co-authored-by: Cursor * fix(dashboard): address PR review — AntD buttons, public page guard, dedupe regex - Replace raw + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.test.tsx b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.test.tsx index 6994def858b..4f07f0e2daa 100644 --- a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.test.tsx @@ -29,14 +29,14 @@ describe("CommunityEngagementButtons", () => { expect(joinSlackLink).toHaveAttribute("rel", "noopener noreferrer"); }); - it("should render Star us on GitHub button with correct link", () => { + it("should render GitHub link with correct href", () => { renderWithProviders(); - const starOnGithubLink = screen.getByRole("link", { name: /star us on github/i }); - expect(starOnGithubLink).toBeInTheDocument(); - expect(starOnGithubLink).toHaveAttribute("href", "https://github.com/BerriAI/litellm"); - expect(starOnGithubLink).toHaveAttribute("target", "_blank"); - expect(starOnGithubLink).toHaveAttribute("rel", "noopener noreferrer"); + const githubLink = screen.getByRole("link", { name: /litellm on github/i }); + expect(githubLink).toBeInTheDocument(); + expect(githubLink).toHaveAttribute("href", "https://github.com/BerriAI/litellm"); + expect(githubLink).toHaveAttribute("target", "_blank"); + expect(githubLink).toHaveAttribute("rel", "noopener noreferrer"); }); it("should not render buttons when prompts are disabled", () => { @@ -45,6 +45,6 @@ describe("CommunityEngagementButtons", () => { renderWithProviders(); expect(screen.queryByRole("link", { name: /join slack/i })).not.toBeInTheDocument(); - expect(screen.queryByRole("link", { name: /star us on github/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("link", { name: /litellm on github/i })).not.toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx index 649bcc0b589..f6a43196a32 100644 --- a/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons.tsx @@ -1,36 +1,45 @@ import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; import { GithubOutlined, SlackOutlined } from "@ant-design/icons"; -import { Button } from "antd"; +import { Tooltip } from "antd"; import React from "react"; +const iconBtnClass = + "inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md border-0 bg-transparent text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 cursor-pointer"; + export const CommunityEngagementButtons: React.FC = () => { const disableShowPrompts = useDisableShowPrompts(); - // Hide buttons if prompts are disabled if (disableShowPrompts) { return null; } return ( - <> - - - +
+ + + + + + + + + + +
); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx new file mode 100644 index 00000000000..4ead085d977 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx @@ -0,0 +1,69 @@ +import { renderWithProviders, screen } from "../../../../tests/test-utils"; +import { NotificationsBell, AGENT_PLATFORM_URL } from "./NotificationsBell"; +import React from "react"; +import userEvent from "@testing-library/user-event"; + +describe("NotificationsBell", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("should open notifications with Agent Platform details and GitHub link", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /^notifications$/i })); + expect(screen.getByText(/LiteLLM Agent Platform/i)).toBeInTheDocument(); + const githubBtn = screen.getByRole("link", { name: /^GitHub$/i }); + expect(githubBtn).toHaveAttribute("href", AGENT_PLATFORM_URL); + expect(githubBtn).toHaveAttribute("target", "_blank"); + expect(githubBtn).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("should offer mark as read when announcement is unread", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /^notifications$/i })); + expect(screen.getByRole("button", { name: /^mark as read$/i })).toBeInTheDocument(); + }); + + it("should hide mark as read and persist after marking read", async () => { + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /^notifications$/i })); + await user.click(screen.getByRole("button", { name: /^mark as read$/i })); + expect(localStorage.getItem("litellmHideAgentPlatformBanner")).toBe("true"); + await user.click(screen.getByRole("button", { name: /^notifications$/i })); + expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument(); + }); + + it("should not show mark as read when previously dismissed", async () => { + localStorage.setItem("litellmHideAgentPlatformBanner", "true"); + const user = userEvent.setup(); + renderWithProviders(); + await user.click(screen.getByRole("button", { name: /^notifications$/i })); + expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument(); + }); + + it("should sync sibling instances when one is dismissed", async () => { + const user = userEvent.setup(); + renderWithProviders( + <> +
+ +
+
+ +
+ , + ); + + // Both bells start unread → both render the "Mark as read" affordance once opened. + const [bellA, bellB] = screen.getAllByRole("button", { name: /^notifications$/i }); + await user.click(bellA); + await user.click(screen.getByRole("button", { name: /^mark as read$/i })); + + // Dismissing in bell A must also clear bell B without a remount. + await user.click(bellB); + expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx new file mode 100644 index 00000000000..a3b7678e1e7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { + HIDE_AGENT_PLATFORM_BANNER_KEY, + useHideAgentPlatformBanner, +} from "@/app/(dashboard)/hooks/useHideAgentPlatformBanner"; +import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils"; +import { BellOutlined } from "@ant-design/icons"; +import { Badge, Button, Popover, Typography } from "antd"; +import React, { useState } from "react"; + +export const AGENT_PLATFORM_URL = "https://github.com/BerriAI/litellm-agent-platform"; + +export const NotificationsBell: React.FC = () => { + const hidden = useHideAgentPlatformBanner(); + const hasUnread = !hidden; + const [open, setOpen] = useState(false); + + const markDismissed = () => { + setLocalStorageItem(HIDE_AGENT_PLATFORM_BANNER_KEY, "true"); + emitLocalStorageChange(HIDE_AGENT_PLATFORM_BANNER_KEY); + setOpen(false); + }; + + const content = ( +
+ + LiteLLM Agent Platform + + + Open-source agent infra — sandboxes, durable sessions, and workers on AWS Fargate. + +
+ + {hasUnread ? ( + + ) : null} +
+
+ ); + + return ( + + + + ); +}; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx index de853303c15..31ddae31798 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx @@ -37,6 +37,8 @@ vi.mock("@/utils/localStorageUtils", () => ({ describe("UserDropdown", () => { const mockOnLogout = vi.fn(); + const getAccountTrigger = () => screen.getByRole("button", { name: /account menu/i }); + beforeEach(() => { vi.clearAllMocks(); mockUseAuthorizedImpl = () => ({ @@ -55,22 +57,23 @@ describe("UserDropdown", () => { it("should render", () => { renderWithProviders(); - expect(screen.getByRole("button")).toBeInTheDocument(); + expect(getAccountTrigger()).toBeInTheDocument(); }); - it("should display user button with User text", () => { + it("should surface initials and account menu affordance", () => { renderWithProviders(); - expect(screen.getByText("User")).toBeInTheDocument(); + expect(getAccountTrigger()).toBeInTheDocument(); + expect(screen.getByText("TE")).toBeInTheDocument(); }); it("should show user email when dropdown is opened", async () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); + expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0); }); }); @@ -78,7 +81,7 @@ describe("UserDropdown", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { expect(screen.getByText("test-user-id")).toBeInTheDocument(); @@ -89,10 +92,10 @@ describe("UserDropdown", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { - expect(screen.getByText("Admin")).toBeInTheDocument(); + expect(screen.getAllByText("Admin").length).toBeGreaterThan(0); }); }); @@ -100,7 +103,7 @@ describe("UserDropdown", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { expect(screen.getByText("Standard")).toBeInTheDocument(); @@ -118,7 +121,7 @@ describe("UserDropdown", () => { renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { expect(screen.getByText("Premium")).toBeInTheDocument(); @@ -129,10 +132,10 @@ describe("UserDropdown", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); + expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0); }); await user.click(screen.getByText("Logout")); @@ -144,10 +147,10 @@ describe("UserDropdown", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); + expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0); }); const toggle = screen.getByLabelText("Toggle hide new feature indicators"); @@ -169,10 +172,10 @@ describe("UserDropdown", () => { renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); + expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0); }); const toggle = screen.getByLabelText("Toggle hide new feature indicators"); @@ -189,10 +192,10 @@ describe("UserDropdown", () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); + expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0); }); const toggle = screen.getByLabelText("Toggle hide all prompts"); @@ -215,10 +218,10 @@ describe("UserDropdown", () => { renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); + expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0); }); const toggle = screen.getByLabelText("Toggle hide all prompts"); @@ -231,6 +234,17 @@ describe("UserDropdown", () => { expect(localStorageUtils.emitLocalStorageChange).toHaveBeenCalledWith("disableShowPrompts"); }); + it("should show Account in the trigger when user id is the default placeholder", () => { + mockUseAuthorizedImpl = () => ({ + userId: "default_user_id", + userEmail: null as any, + userRole: "Admin", + premiumUser: false, + }); + renderWithProviders(); + expect(screen.getByText("Account")).toBeInTheDocument(); + }); + it("should display dash when user email is not available", async () => { const user = userEvent.setup(); mockUseAuthorizedImpl = () => ({ @@ -242,7 +256,7 @@ describe("UserDropdown", () => { renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { expect(screen.getByText("-")).toBeInTheDocument(); @@ -260,7 +274,7 @@ describe("UserDropdown", () => { renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { const dashElements = screen.getAllByText("-"); @@ -277,10 +291,10 @@ describe("UserDropdown", () => { renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(getAccountTrigger()); await waitFor(() => { - expect(screen.getByText("test@example.com")).toBeInTheDocument(); + expect(screen.getAllByText("test@example.com").length).toBeGreaterThan(0); }); const toggle = screen.getByLabelText("Toggle hide new feature indicators"); diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 6490cd32fa7..64a2f1260ba 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -9,6 +9,7 @@ import { removeLocalStorageItem, setLocalStorageItem, } from "@/utils/localStorageUtils"; +import { navAccountDisplayName } from "@/components/Navbar/navDisplayName"; import { CrownOutlined, DownOutlined, @@ -23,6 +24,39 @@ import React, { useEffect, useState } from "react"; const { Text } = Typography; +function hueFromString(seed: string): number { + let h = 0; + for (let i = 0; i < seed.length; i += 1) { + h = seed.charCodeAt(i) + ((h << 5) - h); + } + return Math.abs(h) % 360; +} + +function initialsFromIdentity(email: string | null, userId: string | null): string { + const local = email?.split("@")[0]?.trim(); + if (local) { + const parts = local + .replace(/[^a-zA-Z0-9]+/g, " ") + .trim() + .split(/\s+/) + .filter(Boolean); + if (parts.length >= 2) { + return `${parts[0]!.charAt(0)}${parts[1]!.charAt(0)}`.toUpperCase(); + } + if (parts.length === 1) { + const p = parts[0]!; + return p.length >= 2 ? p.slice(0, 2).toUpperCase() : `${p.charAt(0)}`.toUpperCase(); + } + } + if (userId && userId.length >= 2) { + return userId.slice(0, 2).toUpperCase(); + } + if (userId && userId.length === 1) { + return `${userId.toUpperCase()}•`; + } + return "?"; +} + interface UserDropdownProps { onLogout: () => void; } @@ -61,19 +95,12 @@ const UserDropdown: React.FC = ({ onLogout }) => { {userEmail || "-"} {premiumUser ? ( - } - color="gold" - > + } color="gold"> Premium ) : ( - } - > - Standard - + }>Standard )} @@ -83,12 +110,7 @@ const UserDropdown: React.FC = ({ onLogout }) => { User ID - + {userId || "-"} @@ -189,13 +211,17 @@ const UserDropdown: React.FC = ({ onLogout }) => { ); + const seed = userEmail || userId || "user"; + const initials = initialsFromIdentity(userEmail, userId); + const hue = hueFromString(seed); + const displayName = navAccountDisplayName(userEmail, userId); + return ( ( -
+
{renderUserInfoSection()} {React.cloneElement(menu as React.ReactElement, { @@ -204,12 +230,23 @@ const UserDropdown: React.FC = ({ onLogout }) => {
)} > - ); diff --git a/ui/litellm-dashboard/src/components/Navbar/navDisplayName.test.ts b/ui/litellm-dashboard/src/components/Navbar/navDisplayName.test.ts new file mode 100644 index 00000000000..96e768c5d31 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/navDisplayName.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { navAccountDisplayName } from "./navDisplayName"; + +describe("navAccountDisplayName", () => { + it("should prefer email when present", () => { + expect(navAccountDisplayName("x@y.com", "ignored")).toBe("x@y.com"); + }); + + it("should map default_user_id placeholder to Account", () => { + expect(navAccountDisplayName(null, "default_user_id")).toBe("Account"); + expect(navAccountDisplayName(null, "DEFAULT_USER_ID")).toBe("Account"); + }); + + it("should show a sensible token when user id is non-placeholder", () => { + expect(navAccountDisplayName(null, "user-uuid-123")).toBe("user-uuid-123"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts b/ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts new file mode 100644 index 00000000000..d6f51dc37a8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/navDisplayName.ts @@ -0,0 +1,15 @@ +/** Primary label for the navbar account control — avoids raw placeholder JWT/user IDs in the UI. */ +export function navAccountDisplayName(userEmail: string | null, userId: string | null): string { + const email = userEmail?.trim(); + if (email) { + return email; + } + const id = userId?.trim(); + if (!id) { + return "Account"; + } + if (/^default[_\s-]?user[_\s-]?id$/i.test(id)) { + return "Account"; + } + return id; +} diff --git a/ui/litellm-dashboard/src/components/Navbar/navProductLinkClass.ts b/ui/litellm-dashboard/src/components/Navbar/navProductLinkClass.ts new file mode 100644 index 00000000000..ca4b2e5d1f3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/navProductLinkClass.ts @@ -0,0 +1,3 @@ +/** Shared styling for Docs / Blog in the top nav (product navigation zone). */ +export const NAV_PRODUCT_LINK_CLASS = + "inline-flex h-9 shrink-0 items-center justify-center gap-1 rounded-md px-2 text-sm font-medium leading-none text-gray-800 transition-colors hover:bg-gray-100 hover:text-gray-950"; diff --git a/ui/litellm-dashboard/src/components/navbar.test.tsx b/ui/litellm-dashboard/src/components/navbar.test.tsx index 2e122164969..274e81db527 100644 --- a/ui/litellm-dashboard/src/components/navbar.test.tsx +++ b/ui/litellm-dashboard/src/components/navbar.test.tsx @@ -30,6 +30,7 @@ const mockUserDropdownData = vi.hoisted(() => ({ vi.mock("./Navbar/UserDropdown/UserDropdown", async (importOriginal) => { const React = await import("react"); const { useState } = React; + const { Button } = await import("antd"); const localStorageUtils = await import("@/utils/localStorageUtils"); return { default: function MockUserDropdown({ onLogout }: { onLogout: () => void }) { @@ -37,9 +38,9 @@ vi.mock("./Navbar/UserDropdown/UserDropdown", async (importOriginal) => { const [open, setOpen] = useState(false); return (
- + {open && (
{userId} @@ -136,30 +137,25 @@ Object.defineProperty(window, "location", { describe("Navbar", () => { const defaultProps = { - userID: "test-user", - userEmail: "test@example.com", - userRole: "Admin", - premiumUser: false, proxySettings: {}, setProxySettings: vi.fn(), accessToken: "test-token", isPublicPage: false, - isDarkMode: false, - toggleDarkMode: vi.fn(), }; it("should render without crashing", () => { renderWithProviders(); + expect(screen.getByRole("button", { name: /^notifications$/i })).toBeInTheDocument(); expect(screen.getByText("Docs")).toBeInTheDocument(); - expect(screen.getByText("User")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /open account menu/i })).toBeInTheDocument(); }); it("should display user information in dropdown", async () => { const user = userEvent.setup(); renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(screen.getByRole("button", { name: /open account menu/i })); await waitFor(() => { expect(screen.getByText("test-user")).toBeInTheDocument(); @@ -198,7 +194,7 @@ describe("Navbar", () => { }); renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(screen.getByRole("button", { name: /open account menu/i })); await waitFor(() => { expect(screen.getByText("Premium")).toBeInTheDocument(); @@ -247,11 +243,12 @@ describe("Navbar", () => { mockUseThemeImpl = () => ({ logoUrl: null }); }); - it("should hide user dropdown on public pages", () => { + it("should hide user dropdown and notifications on public pages", () => { const publicPageProps = { ...defaultProps, isPublicPage: true }; renderWithProviders(); - expect(screen.queryByText("User")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /open account menu/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /^notifications$/i })).not.toBeInTheDocument(); }); it("should handle hide new features toggle", async () => { @@ -265,7 +262,7 @@ describe("Navbar", () => { renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(screen.getByRole("button", { name: /open account menu/i })); await waitFor(() => { expect(screen.getByText("test-user")).toBeInTheDocument(); @@ -290,7 +287,7 @@ describe("Navbar", () => { renderWithProviders(); - await user.click(screen.getByText("User")); + await user.click(screen.getByRole("button", { name: /open account menu/i })); await waitFor(() => { expect(screen.getByText("test-user")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 055038b6d88..e5a1490788c 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -1,47 +1,39 @@ import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; import { useDisableBouncingIcon } from "@/app/(dashboard)/hooks/useDisableBouncingIcon"; +import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; +import { useWorker } from "@/hooks/useWorker"; import { getProxyBaseUrl } from "@/components/networking"; import { useTheme } from "@/contexts/ThemeContext"; import { clearTokenCookies } from "@/utils/cookieUtils"; import { clearStoredReturnUrl } from "@/utils/returnUrlUtils"; import { fetchProxySettings } from "@/utils/proxyUtils"; -import { MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, SunOutlined } from "@ant-design/icons"; -import { Button, Switch, Tag } from "antd"; +import { DownOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from "@ant-design/icons"; +import { Tag } from "antd"; import Link from "next/link"; import React, { useEffect, useState } from "react"; import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown"; import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons"; +import { NAV_PRODUCT_LINK_CLASS } from "./Navbar/navProductLinkClass"; +import { NotificationsBell } from "./Navbar/NotificationsBell/NotificationsBell"; import UserDropdown from "./Navbar/UserDropdown/UserDropdown"; import WorkerDropdown from "./Navbar/WorkerDropdown/WorkerDropdown"; interface NavbarProps { - userID: string | null; - userEmail: string | null; - userRole: string | null; - premiumUser: boolean; proxySettings: any; setProxySettings: React.Dispatch>; accessToken: string | null; isPublicPage: boolean; sidebarCollapsed?: boolean; onToggleSidebar?: () => void; - isDarkMode: boolean; - toggleDarkMode: () => void; } const Navbar: React.FC = ({ - userID, - userEmail, - userRole, - premiumUser, proxySettings, setProxySettings, accessToken, isPublicPage = false, sidebarCollapsed = false, onToggleSidebar, - isDarkMode, - toggleDarkMode, }) => { const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); @@ -49,8 +41,10 @@ const Navbar: React.FC = ({ const { data: healthData } = useHealthReadinessDetails(accessToken); const version = healthData?.litellm_version; const disableBouncingIcon = useDisableBouncingIcon(); + const hideCommunityLinks = useDisableShowPrompts(); + const { isControlPlane, selectedWorker } = useWorker(); + const showWorkerSwitch = isControlPlane && selectedWorker !== null; - // Simple logo URL: use custom logo if available, otherwise default const imageUrl = logoUrl || `${baseUrl}/get_image`; useEffect(() => { @@ -87,14 +81,14 @@ const Navbar: React.FC = ({ }; return ( -