diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 3b34440d0bf..3f6ef89fbc6 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -641,6 +641,7 @@ class LiteLLMRoutes(enum.Enum):
"/v1/models",
"/sso/get/ui_settings",
"/get/user_banner",
+ "/get/latest_release_info",
]
# NOTE: ROUTES ONLY FOR MASTER KEY - only the Master Key should be able to Reset Spend
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 04e6ee1d23c..8b38290ec53 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -730,6 +730,9 @@ from litellm.proxy.spend_tracking.spend_management_endpoints import (
)
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
from litellm.proxy.types_utils.utils import get_instance_fn
+from litellm.proxy.ui_crud_endpoints.latest_release_endpoints import (
+ router as latest_release_endpoints_router,
+)
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
router as ui_crud_endpoints_router,
)
@@ -19267,6 +19270,7 @@ app.include_router(debugging_endpoints_router)
app.include_router(rust_control_plane_router)
app.include_router(ui_crud_endpoints_router)
app.include_router(user_banner_endpoints_router)
+app.include_router(latest_release_endpoints_router)
app.include_router(team_callback_router)
app.include_router(budget_management_router)
app.include_router(model_management_router)
diff --git a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py
new file mode 100644
index 00000000000..ad5cc8efc31
--- /dev/null
+++ b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py
@@ -0,0 +1,153 @@
+import asyncio
+import re
+from collections import Counter
+from collections.abc import Awaitable, Mapping
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import Annotated, Final, Literal, Protocol, TypeAlias
+
+import httpx
+from fastapi import APIRouter, Depends
+from pydantic import BaseModel, ValidationError
+
+from litellm._logging import verbose_proxy_logger
+from litellm.caching.in_memory_cache import InMemoryCache
+from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+
+router: Final = APIRouter()
+
+LATEST_RELEASE_URL: Final = "https://api.github.com/repos/BerriAI/litellm/releases/latest"
+LATEST_RELEASE_FETCH_TIMEOUT_SECONDS: Final = 5
+LATEST_RELEASE_CACHE_TTL_SECONDS: Final = 60 * 60
+LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS: Final = 5 * 60
+LATEST_RELEASE_CACHE_KEY: Final = "latest_release_info"
+
+_RELEASE_BULLET_PATTERN: Final = re.compile(r"^\*\s+(?:([A-Za-z]+)(?:\([^)]*\))?!?:\s)?\S")
+_NEW_CONTRIBUTOR_PATTERN: Final = re.compile(r"^\*\s+@\S+ made their first contribution\b")
+
+_Bucket: TypeAlias = Literal["new_features", "bug_fixes", "other_updates"]
+_PREFIX_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"feat": "new_features", "fix": "bug_fixes"})
+
+
+class LatestReleaseInfo(BaseModel):
+ version: str
+ new_features: int
+ bug_fixes: int
+ other_updates: int
+ release_url: str
+
+
+@dataclass(frozen=True, slots=True)
+class LatestReleaseUnavailable:
+ reason: str
+
+
+class _GitHubRelease(BaseModel):
+ tag_name: str
+ html_url: str
+ body: str
+
+
+class _AsyncGetClient(Protocol):
+ def get(self, url: str, *, timeout: float | None = None) -> Awaitable[httpx.Response]: ...
+
+
+_latest_release_cache: Final = InMemoryCache(max_size_in_memory=1, default_ttl=LATEST_RELEASE_CACHE_TTL_SECONDS)
+_latest_release_fetch_lock: Final = asyncio.Lock()
+
+
+def _default_client() -> _AsyncGetClient:
+ from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
+ from litellm.types.llms.custom_http import httpxSpecialProvider
+
+ return get_async_httpx_client(llm_provider=httpxSpecialProvider.UI)
+
+
+def _default_cache() -> InMemoryCache:
+ return _latest_release_cache
+
+
+def _default_fetch_lock() -> asyncio.Lock:
+ return _latest_release_fetch_lock
+
+
+def _bucket_for(line: str) -> _Bucket | None:
+ if _NEW_CONTRIBUTOR_PATTERN.match(line) is not None:
+ return None
+ match: Final = _RELEASE_BULLET_PATTERN.match(line)
+ if match is None:
+ return None
+ prefix: Final = match.group(1)
+ return "other_updates" if prefix is None else _PREFIX_BUCKETS.get(prefix.lower(), "other_updates")
+
+
+def count_release_bullets(body: str) -> Mapping[_Bucket, int]:
+ """Bucket release-note bullets by conventional-commit type or ``other_updates``."""
+ return MappingProxyType(Counter(bucket for line in body.splitlines() if (bucket := _bucket_for(line)) is not None))
+
+
+def parse_latest_release(response: httpx.Response) -> LatestReleaseInfo | LatestReleaseUnavailable:
+ if response.status_code != 200:
+ return LatestReleaseUnavailable(reason=f"GitHub responded with status {response.status_code}")
+ try:
+ release: Final = _GitHubRelease.model_validate_json(response.content)
+ except ValidationError as e:
+ return LatestReleaseUnavailable(reason=f"GitHub release payload was not the expected shape: {e}")
+ counts: Final = count_release_bullets(release.body)
+ return LatestReleaseInfo(
+ version=release.tag_name.removeprefix("v"),
+ new_features=counts.get("new_features", 0),
+ bug_fixes=counts.get("bug_fixes", 0),
+ other_updates=counts.get("other_updates", 0),
+ release_url=release.html_url,
+ )
+
+
+async def fetch_latest_release(client: _AsyncGetClient) -> LatestReleaseInfo | LatestReleaseUnavailable:
+ try:
+ response: Final = await client.get(LATEST_RELEASE_URL, timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS)
+ except httpx.HTTPError as e:
+ return LatestReleaseUnavailable(reason=f"{type(e).__name__}: {e}")
+ return parse_latest_release(response)
+
+
+async def get_latest_release_info(
+ client: _AsyncGetClient, cache: InMemoryCache, fetch_lock: asyncio.Lock
+) -> LatestReleaseInfo | LatestReleaseUnavailable:
+ cached: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY)
+ if isinstance(cached, (LatestReleaseInfo, LatestReleaseUnavailable)):
+ return cached
+ async with fetch_lock:
+ cached_after_lock: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY)
+ if isinstance(cached_after_lock, (LatestReleaseInfo, LatestReleaseUnavailable)):
+ return cached_after_lock
+ result: Final = await fetch_latest_release(client)
+ ttl: Final = (
+ LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS
+ if isinstance(result, LatestReleaseUnavailable)
+ else LATEST_RELEASE_CACHE_TTL_SECONDS
+ )
+ cache.set_cache(LATEST_RELEASE_CACHE_KEY, result, ttl=ttl)
+ return result
+
+
+@router.get(
+ "/get/latest_release_info",
+ tags=["UI Settings"], # mutable-ok: FastAPI's route decorator only accepts a list
+ dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI's route decorator only accepts a list
+ response_model=LatestReleaseInfo | None,
+)
+async def latest_release_info(
+ client: Annotated[_AsyncGetClient, Depends(_default_client)],
+ cache: Annotated[InMemoryCache, Depends(_default_cache)],
+ fetch_lock: Annotated[asyncio.Lock, Depends(_default_fetch_lock)],
+) -> LatestReleaseInfo | None:
+ """
+ Latest stable LiteLLM GitHub release with its PR count split into new features, bug fixes and other updates.
+ Returns null when GitHub can't be reached so the dashboard upgrade banner simply doesn't render.
+ """
+ result: Final = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
+ if isinstance(result, LatestReleaseUnavailable):
+ verbose_proxy_logger.warning("LiteLLM: latest release info unavailable: %s", result.reason)
+ return None
+ return result
diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py
index 603a8686692..87bf4595af5 100644
--- a/tests/test_litellm/proxy/auth/test_route_checks.py
+++ b/tests/test_litellm/proxy/auth/test_route_checks.py
@@ -120,6 +120,33 @@ def test_user_banner_read_open_to_non_admin_roles(role):
)
+@pytest.mark.parametrize(
+ "role",
+ [
+ LitellmUserRoles.INTERNAL_USER.value,
+ LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
+ ],
+)
+def test_latest_release_info_read_open_to_non_admin_roles(role): # test-quality-ok: allowed path returns None, not raising is the observable
+ user_obj = LiteLLM_UserTable(
+ user_id="test_user",
+ user_email="test@example.com",
+ user_role=role,
+ )
+ valid_token = UserAPIKeyAuth(user_id="test_user", user_role=role)
+ request = MagicMock(spec=Request)
+ request.query_params = {}
+
+ RouteChecks.non_proxy_admin_allowed_routes_check(
+ user_obj=user_obj,
+ _user_role=role,
+ route="/get/latest_release_info",
+ request=request,
+ valid_token=valid_token,
+ request_data={},
+ )
+
+
def test_user_banner_update_rejected_for_non_admin():
"""Publishing the banner stays admin-only at the route layer."""
user_obj = LiteLLM_UserTable(
diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py
new file mode 100644
index 00000000000..c966b8b7135
--- /dev/null
+++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py
@@ -0,0 +1,260 @@
+import asyncio
+import json
+import time
+from typing import Final
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+
+from litellm.caching.in_memory_cache import InMemoryCache
+from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
+from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+from litellm.proxy.proxy_server import app
+from litellm.proxy.ui_crud_endpoints.latest_release_endpoints import (
+ LATEST_RELEASE_CACHE_KEY,
+ LATEST_RELEASE_CACHE_TTL_SECONDS,
+ LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS,
+ LATEST_RELEASE_URL,
+ LatestReleaseInfo,
+ LatestReleaseUnavailable,
+ _default_cache,
+ _default_client,
+ _default_fetch_lock,
+ count_release_bullets,
+ get_latest_release_info,
+)
+
+SAMPLE_BODY: Final = """## What's Changed
+* feat(proxy): add upgrade banner by @kerry in https://github.com/BerriAI/litellm/pull/1
+* fix(azure): retry on 429 by @a in https://github.com/BerriAI/litellm/pull/2
+* fix: handle empty body by @b in https://github.com/BerriAI/litellm/pull/3
+* Feat(ui)!: drop legacy theme by @c in https://github.com/BerriAI/litellm/pull/4
+* chore(deps): bump httpx by @d in https://github.com/BerriAI/litellm/pull/5
+* docs: fix typo by @e in https://github.com/BerriAI/litellm/pull/6
+* Litellm dev 09 08 2026 by @f in https://github.com/BerriAI/litellm/pull/7
+* refactor(router) : spaced colon does not match by @g in https://github.com/BerriAI/litellm/pull/8
+
+## New Contributors
+* @kerry made their first contribution in https://github.com/BerriAI/litellm/pull/1
+
+**Full Changelog**: https://github.com/BerriAI/litellm/compare/v1.101.0...v1.102.0
+"""
+
+SAMPLE_RELEASE: Final = {
+ "tag_name": "v1.102.0",
+ "html_url": "https://github.com/BerriAI/litellm/releases/tag/v1.102.0",
+ "body": SAMPLE_BODY,
+}
+EXPECTED_INFO: Final = {
+ "version": "1.102.0",
+ "new_features": 2,
+ "bug_fixes": 2,
+ "other_updates": 4,
+ "release_url": SAMPLE_RELEASE["html_url"],
+}
+
+
+class _RecordingClient:
+ def __init__(self, outcomes: list[httpx.Response | Exception]) -> None:
+ self._outcomes = outcomes
+ self.calls: list[tuple[str, float | None]] = []
+
+ async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response:
+ self.calls.append((url, timeout))
+ outcome = self._outcomes[min(len(self.calls) - 1, len(self._outcomes) - 1)]
+ if isinstance(outcome, Exception):
+ raise outcome
+ return outcome
+
+
+def _github_response(status: int = 200, payload: object = SAMPLE_RELEASE) -> httpx.Response:
+ return httpx.Response(status, content=json.dumps(payload).encode())
+
+
+def _fresh_cache() -> InMemoryCache:
+ return InMemoryCache(max_size_in_memory=1, default_ttl=LATEST_RELEASE_CACHE_TTL_SECONDS)
+
+
+def _override_dependencies(client: _RecordingClient, cache: InMemoryCache, role: LitellmUserRoles) -> None:
+ async def auth() -> UserAPIKeyAuth:
+ return UserAPIKeyAuth(api_key="sk-test", user_id="test-user", user_role=role)
+
+ app.dependency_overrides[user_api_key_auth] = auth
+ app.dependency_overrides[_default_client] = lambda: client
+ app.dependency_overrides[_default_cache] = lambda: cache
+ app.dependency_overrides[_default_fetch_lock] = lambda: asyncio.Lock()
+
+
+@pytest.fixture
+def http_client():
+ yield TestClient(app)
+ app.dependency_overrides.pop(user_api_key_auth, None)
+ app.dependency_overrides.pop(_default_client, None)
+ app.dependency_overrides.pop(_default_cache, None)
+ app.dependency_overrides.pop(_default_fetch_lock, None)
+
+
+class TestCountReleaseBullets:
+ def test_buckets_by_conventional_commit_type(self):
+ counts = count_release_bullets(SAMPLE_BODY)
+ assert counts["new_features"] == 2
+ assert counts["bug_fixes"] == 2
+ assert counts["other_updates"] == 4
+
+ def test_unprefixed_bullets_count_as_other_updates(self):
+ counts = count_release_bullets("* Litellm dev 09 08 2026 by @f in https://x/pull/7\n")
+ assert (counts["new_features"], counts["bug_fixes"], counts["other_updates"]) == (0, 0, 1)
+
+ def test_ignores_non_bullet_lines_and_contributor_entries(self):
+ assert (
+ sum(
+ count_release_bullets(
+ "## What's Changed\n\n* @x made their first contribution in url\n"
+ "\n**Full Changelog**: https://github.com/BerriAI/litellm/compare/v1...v2\n"
+ ).values()
+ )
+ == 0
+ )
+
+ def test_empty_body_yields_zero_counts(self):
+ counts = count_release_bullets("")
+ assert (counts["new_features"], counts["bug_fixes"], counts["other_updates"]) == (0, 0, 0)
+
+
+class TestGetLatestReleaseInfo:
+ @pytest.mark.asyncio
+ async def test_fetches_and_parses_github_release(self):
+ client = _RecordingClient([_github_response()])
+ result = await get_latest_release_info(client=client, cache=_fresh_cache(), fetch_lock=asyncio.Lock())
+ assert isinstance(result, LatestReleaseInfo)
+ assert result.model_dump() == EXPECTED_INFO
+ assert client.calls == [(LATEST_RELEASE_URL, 5)]
+
+ @pytest.mark.asyncio
+ async def test_second_call_within_ttl_does_not_refetch(self):
+ client = _RecordingClient([_github_response()])
+ cache = _fresh_cache()
+ fetch_lock = asyncio.Lock()
+ first = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
+ second = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
+ assert first == second
+ assert len(client.calls) == 1
+
+ @pytest.mark.asyncio
+ async def test_success_is_cached_for_the_full_ttl(self):
+ cache = _fresh_cache()
+ await get_latest_release_info(
+ client=_RecordingClient([_github_response()]), cache=cache, fetch_lock=asyncio.Lock()
+ )
+ remaining = await cache.async_get_ttl(LATEST_RELEASE_CACHE_KEY) - time.time()
+ assert LATEST_RELEASE_CACHE_TTL_SECONDS - 5 < remaining <= LATEST_RELEASE_CACHE_TTL_SECONDS
+
+ @pytest.mark.asyncio
+ async def test_failure_is_cached_briefly_so_github_is_not_hammered(self):
+ client = _RecordingClient([httpx.ConnectError("boom")])
+ cache = _fresh_cache()
+ fetch_lock = asyncio.Lock()
+ first = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
+ second = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
+ assert isinstance(first, LatestReleaseUnavailable)
+ assert first == second
+ assert len(client.calls) == 1
+ remaining = await cache.async_get_ttl(LATEST_RELEASE_CACHE_KEY) - time.time()
+ assert (
+ LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS - 5 < remaining <= LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS
+ )
+
+ @pytest.mark.asyncio
+ @pytest.mark.parametrize(
+ "response",
+ [
+ _github_response(status=403, payload={"message": "rate limited"}),
+ _github_response(status=500, payload={}),
+ _github_response(payload={"tag_name": "v1.0.0"}),
+ httpx.Response(200, content=b"not json"),
+ ],
+ ids=["rate_limited", "server_error", "missing_fields", "not_json"],
+ )
+ async def test_bad_github_responses_are_unavailable(self, response: httpx.Response):
+ result = await get_latest_release_info(
+ client=_RecordingClient([response]), cache=_fresh_cache(), fetch_lock=asyncio.Lock()
+ )
+ assert isinstance(result, LatestReleaseUnavailable)
+
+ @pytest.mark.asyncio
+ async def test_concurrent_misses_share_one_fetch(self):
+ event = asyncio.Event()
+
+ class _BlockingClient(_RecordingClient):
+ async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response:
+ self.calls.append((url, timeout))
+ await event.wait()
+ return _github_response()
+
+ client = _BlockingClient([])
+ cache = _fresh_cache()
+ fetch_lock = asyncio.Lock()
+ tasks = [
+ asyncio.create_task(get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock))
+ for _ in range(5)
+ ]
+ await asyncio.sleep(0)
+ await asyncio.sleep(0)
+ event.set()
+ results = await asyncio.gather(*tasks)
+ expected: Final = LatestReleaseInfo.model_validate(EXPECTED_INFO)
+ assert results == [expected] * 5
+ assert len(client.calls) == 1
+
+ @pytest.mark.asyncio
+ async def test_failure_under_lock_is_also_coalesced(self):
+ event = asyncio.Event()
+
+ class _FailingBlockingClient(_RecordingClient):
+ async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response:
+ self.calls.append((url, timeout))
+ await event.wait()
+ raise httpx.ConnectError("boom")
+
+ client = _FailingBlockingClient([])
+ cache = _fresh_cache()
+ fetch_lock = asyncio.Lock()
+ tasks = [
+ asyncio.create_task(get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock))
+ for _ in range(5)
+ ]
+ await asyncio.sleep(0)
+ await asyncio.sleep(0)
+ event.set()
+ results = await asyncio.gather(*tasks)
+ assert all(isinstance(result, LatestReleaseUnavailable) for result in results)
+ assert len(client.calls) == 1
+
+
+class TestLatestReleaseInfoEndpoint:
+ def test_returns_release_stats_for_authenticated_user(self, http_client):
+ _override_dependencies(_RecordingClient([_github_response()]), _fresh_cache(), LitellmUserRoles.INTERNAL_USER)
+ response = http_client.get("/get/latest_release_info")
+ assert response.status_code == 200
+ assert response.json() == EXPECTED_INFO
+
+ def test_returns_null_when_github_is_unreachable(self, http_client):
+ _override_dependencies(
+ _RecordingClient([httpx.ConnectError("boom")]), _fresh_cache(), LitellmUserRoles.PROXY_ADMIN
+ )
+ response = http_client.get("/get/latest_release_info")
+ assert response.status_code == 200
+ assert response.json() is None
+
+ def test_repeated_requests_reuse_cache(self, http_client):
+ client = _RecordingClient([_github_response()])
+ _override_dependencies(client, _fresh_cache(), LitellmUserRoles.PROXY_ADMIN)
+ assert http_client.get("/get/latest_release_info").json() == EXPECTED_INFO
+ assert http_client.get("/get/latest_release_info").json() == EXPECTED_INFO
+ assert len(client.calls) == 1
+
+ def test_rejects_unauthenticated_requests(self, monkeypatch):
+ monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-1234")
+ response = TestClient(app).get("/get/latest_release_info")
+ assert response.status_code in (401, 403)
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts
new file mode 100644
index 00000000000..5186baa605c
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts
@@ -0,0 +1,16 @@
+import { $api } from "@/lib/http/api";
+import type { components } from "@/lib/http/schema";
+
+export type LatestReleaseInfo = components["schemas"]["LatestReleaseInfo"];
+
+export const useLatestReleaseInfo = (accessToken: string | null | undefined) =>
+ $api.useQuery(
+ "get",
+ "/get/latest_release_info",
+ {},
+ {
+ enabled: Boolean(accessToken),
+ staleTime: 60 * 60 * 1000,
+ retry: false,
+ },
+ );
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx
index 3fe34610260..d7e1bb82564 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx
@@ -41,6 +41,10 @@ vi.mock("@/components/UserBanner", () => ({
UserBanner: () => null,
}));
+vi.mock("@/components/UpgradeBanner", () => ({
+ UpgradeBanner: () => null,
+}));
+
vi.mock("@/contexts/ThemeContext", () => ({
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}>,
}));
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx
index fa6df7f176a..6d326f5280e 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx
@@ -13,6 +13,7 @@ import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner";
import { EnvCredentialLoginWarningBanner } from "@/components/EnvCredentialLoginWarningBanner";
import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner";
import { UserBanner } from "@/components/UserBanner";
+import { UpgradeBanner } from "@/components/UpgradeBanner";
import { uiHref } from "@/utils/uiHref";
import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext";
import { createApiClient } from "@/lib/http/client";
@@ -117,6 +118,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
+
@@ -137,6 +139,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
+
{children}
diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx
new file mode 100644
index 00000000000..4466597394c
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx
@@ -0,0 +1,131 @@
+import React from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describeRelease, UpgradeBanner, UpgradeBannerView } from "./UpgradeBanner";
+import type { LatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
+
+vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({
+ useHealthReadinessDetails: vi.fn(),
+}));
+vi.mock("@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo", () => ({
+ useLatestReleaseInfo: vi.fn(),
+}));
+
+import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
+import { useLatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
+
+const RELEASE: LatestReleaseInfo = {
+ version: "1.103.0",
+ new_features: 12,
+ bug_fixes: 30,
+ other_updates: 8,
+ release_url: "https://github.com/BerriAI/litellm/releases/tag/v1.103.0",
+};
+
+describe("describeRelease", () => {
+ it("lists features, fixes, and other updates in the agreed order", () => {
+ expect(describeRelease(RELEASE)).toBe("12 new features, 30 fixes, and 8 other updates");
+ });
+
+ it("singularises counts of one", () => {
+ const singularCounts = { ...RELEASE, new_features: 1, bug_fixes: 1, other_updates: 1 };
+ expect(describeRelease(singularCounts)).toBe("1 new feature, 1 fix, and 1 other update");
+ });
+});
+
+describe("UpgradeBannerView", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it("renders nothing while either version is unknown", () => {
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ const { container: noRelease } = render();
+ expect(noRelease).toBeEmptyDOMElement();
+ });
+
+ it("renders nothing when the running version is up to date or ahead", () => {
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ const { container: ahead } = render();
+ expect(ahead).toBeEmptyDOMElement();
+ });
+
+ it("shows the latest version, the stat line, and the current version when behind", () => {
+ render();
+ const alert = screen.getByRole("status");
+ expect(alert).toHaveTextContent("The latest version is v1.103.0: 12 new features, 30 fixes, and 8 other updates");
+ expect(alert).toHaveTextContent("Your current version is v1.102.0");
+ expect(screen.getByRole("link", { name: "v1.103.0" })).toHaveAttribute("href", RELEASE.release_url);
+ });
+
+ it("dismissing hides the banner and keeps it hidden on remount for the same release", () => {
+ const { unmount } = render();
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ unmount();
+
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it("reappears once a newer release ships after a dismissal", () => {
+ const { unmount } = render();
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ unmount();
+
+ render();
+ expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
+ });
+
+ it("shows a newer release after the current one was dismissed without remounting", () => {
+ const { rerender } = render();
+ fireEvent.click(screen.getByRole("button", { name: "Close" }));
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ rerender();
+ expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
+ });
+});
+
+describe("UpgradeBanner", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it("feeds both hooks the access token and renders from their data", () => {
+ const healthReadinessResult = {
+ data: { litellm_version: "1.102.0" },
+ } as Partial> as ReturnType;
+ const latestReleaseResult = { data: RELEASE } as Partial> as ReturnType<
+ typeof useLatestReleaseInfo
+ >;
+ vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
+ vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
+ render();
+ expect(useHealthReadinessDetails).toHaveBeenCalledWith("token");
+ expect(useLatestReleaseInfo).toHaveBeenCalledWith("token");
+ expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.103.0");
+ });
+
+ it("renders nothing when the release endpoint returns null", () => {
+ const healthReadinessResult = {
+ data: { litellm_version: "1.102.0" },
+ } as Partial> as ReturnType;
+ const latestReleaseResult = { data: null } as Partial> as ReturnType<
+ typeof useLatestReleaseInfo
+ >;
+ vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
+ vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
+ const { container } = render();
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx
new file mode 100644
index 00000000000..101a6e3410d
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import React, { useState } from "react";
+import { ArrowUpCircle, X } from "lucide-react";
+import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
+import { Button } from "@/components/ui/button";
+import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
+import {
+ type LatestReleaseInfo,
+ useLatestReleaseInfo,
+} from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
+import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
+import { isNewerVersion } from "@/utils/versionUtils";
+
+const DISMISS_KEY_PREFIX = "litellm:upgradeBannerDismissed:";
+
+interface UpgradeBannerProps {
+ accessToken: string | null;
+}
+
+interface UpgradeBannerViewProps {
+ currentVersion: string | null | undefined;
+ latestRelease: LatestReleaseInfo | null | undefined;
+}
+
+const plural = (count: number, singular: string, pluralForm: string): string =>
+ `${count} ${count === 1 ? singular : pluralForm}`;
+
+export const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string =>
+ [
+ plural(new_features, "new feature", "new features"),
+ plural(bug_fixes, "fix", "fixes"),
+ `and ${plural(other_updates, "other update", "other updates")}`,
+ ].join(", ");
+
+export const UpgradeBannerView: React.FC = ({ currentVersion, latestRelease }) => {
+ const [dismissedVersion, setDismissedVersion] = useState(null);
+
+ if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) {
+ return null;
+ }
+
+ const dismissKey = `${DISMISS_KEY_PREFIX}${latestRelease.version}`;
+ if (dismissedVersion === latestRelease.version || getLocalStorageItem(dismissKey) === "true") {
+ return null;
+ }
+
+ const handleClose = () => {
+ setLocalStorageItem(dismissKey, "true");
+ setDismissedVersion(latestRelease.version);
+ };
+
+ return (
+
+
+
+ The latest version is{" "}
+
+ v{latestRelease.version}
+
+ : {describeRelease(latestRelease)}
+
+ Your current version is v{currentVersion}
+
+
+
+
+ );
+};
+
+export const UpgradeBanner: React.FC = ({ accessToken }) => {
+ const { data: healthData } = useHealthReadinessDetails(accessToken);
+ const { data: latestRelease } = useLatestReleaseInfo(accessToken);
+ return ;
+};
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 440958fa412..d9c7e370d4f 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -5319,6 +5319,27 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/get/latest_release_info": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Latest Release Info
+ * @description Latest stable LiteLLM GitHub release with its PR count split into new features, bug fixes and other updates.
+ * Returns null when GitHub can't be reached so the dashboard upgrade banner simply doesn't render.
+ */
+ get: operations["latest_release_info_get_latest_release_info_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/get/mcp_semantic_filter_settings": {
parameters: {
query?: never;
@@ -29831,6 +29852,19 @@ export interface components {
} & {
[key: string]: unknown;
};
+ /** LatestReleaseInfo */
+ LatestReleaseInfo: {
+ /** Bug Fixes */
+ bug_fixes: number;
+ /** New Features */
+ new_features: number;
+ /** Other Updates */
+ other_updates: number;
+ /** Release Url */
+ release_url: string;
+ /** Version */
+ version: string;
+ };
/** ListAccessGroupsResponse */
ListAccessGroupsResponse: {
/** Access Groups */
@@ -49914,6 +49948,26 @@ export interface operations {
};
};
};
+ latest_release_info_get_latest_release_info_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["LatestReleaseInfo"] | null;
+ };
+ };
+ };
+ };
get_mcp_semantic_filter_settings_get_mcp_semantic_filter_settings_get: {
parameters: {
query?: never;
diff --git a/ui/litellm-dashboard/src/utils/versionUtils.test.ts b/ui/litellm-dashboard/src/utils/versionUtils.test.ts
new file mode 100644
index 00000000000..714c7165d6d
--- /dev/null
+++ b/ui/litellm-dashboard/src/utils/versionUtils.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+import { isNewerVersion, parseReleaseVersion } from "./versionUtils";
+
+describe("parseReleaseVersion", () => {
+ it("reads the numeric components with or without a leading v", () => {
+ expect(parseReleaseVersion("1.102.0")).toEqual([1, 102, 0]);
+ expect(parseReleaseVersion("v1.102.0")).toEqual([1, 102, 0]);
+ });
+
+ it("ignores a prerelease suffix", () => {
+ expect(parseReleaseVersion("1.102.0-dev.3")).toEqual([1, 102, 0]);
+ expect(parseReleaseVersion("1.102.0.rc1")).toEqual([1, 102, 0]);
+ });
+
+ it("returns null for strings that are not a release version", () => {
+ expect(parseReleaseVersion("")).toBeNull();
+ expect(parseReleaseVersion("latest")).toBeNull();
+ expect(parseReleaseVersion("1.102")).toBeNull();
+ });
+});
+
+describe("isNewerVersion", () => {
+ it("is false when the versions are equal", () => {
+ expect(isNewerVersion("1.102.0", "1.102.0")).toBe(false);
+ expect(isNewerVersion("1.102.0", "v1.102.0")).toBe(false);
+ });
+
+ it("is true when the latest version is ahead on any component", () => {
+ expect(isNewerVersion("1.102.0", "1.102.1")).toBe(true);
+ expect(isNewerVersion("1.102.5", "1.103.0")).toBe(true);
+ expect(isNewerVersion("1.999.9", "2.0.0")).toBe(true);
+ });
+
+ it("is false when the running version is already ahead", () => {
+ expect(isNewerVersion("1.103.0", "1.102.9")).toBe(false);
+ expect(isNewerVersion("2.0.0", "1.999.9")).toBe(false);
+ });
+
+ it("compares components numerically rather than as strings", () => {
+ expect(isNewerVersion("1.9.0", "1.10.0")).toBe(true);
+ expect(isNewerVersion("1.10.0", "1.9.0")).toBe(false);
+ });
+
+ it("treats a prerelease of the latest version as not behind", () => {
+ expect(isNewerVersion("1.102.0-dev.1", "1.102.0")).toBe(false);
+ expect(isNewerVersion("1.101.0-dev.1", "1.102.0")).toBe(true);
+ });
+
+ it("is false when either version cannot be parsed", () => {
+ expect(isNewerVersion("unknown", "1.102.0")).toBe(false);
+ expect(isNewerVersion("1.102.0", "")).toBe(false);
+ });
+});
diff --git a/ui/litellm-dashboard/src/utils/versionUtils.ts b/ui/litellm-dashboard/src/utils/versionUtils.ts
new file mode 100644
index 00000000000..62fcb97c0ef
--- /dev/null
+++ b/ui/litellm-dashboard/src/utils/versionUtils.ts
@@ -0,0 +1,23 @@
+const RELEASE_VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)/;
+
+export const parseReleaseVersion = (version: string): readonly [number, number, number] | null => {
+ const match = RELEASE_VERSION_PATTERN.exec(version.trim());
+ if (!match) {
+ return null;
+ }
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
+};
+
+export const isNewerVersion = (current: string, latest: string): boolean => {
+ const currentParts = parseReleaseVersion(current);
+ const latestParts = parseReleaseVersion(latest);
+ if (!currentParts || !latestParts) {
+ return false;
+ }
+ for (let i = 0; i < 3; i += 1) {
+ if (latestParts[i] !== currentParts[i]) {
+ return latestParts[i] > currentParts[i];
+ }
+ }
+ return false;
+};