mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
feat(ui): add upgrade banner with latest release changelog stats
Adds GET /get/latest_release_info, which fetches the latest stable GitHub release once per hour per worker and buckets its PR bullets by conventional commit prefix into new features, fixes, and other updates. The Admin UI compares the running proxy version to that release and shows a dismissible top banner with the stat line when the proxy is behind. Dismissal is stored per release version in localStorage so the banner returns for the next one. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
1183b2abc6
commit
1a358318f8
10 changed files with 667 additions and 0 deletions
|
|
@ -647,6 +647,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,
|
||||
)
|
||||
|
|
@ -18368,6 +18371,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)
|
||||
|
|
|
|||
138
litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py
Normal file
138
litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import re
|
||||
from collections import Counter
|
||||
from collections.abc import Awaitable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import Final, Literal, Protocol
|
||||
|
||||
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")
|
||||
|
||||
_Bucket = 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)
|
||||
|
||||
|
||||
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 count_release_bullets(body: str) -> Counter[_Bucket]:
|
||||
"""
|
||||
Bucket a release body's ``* type(scope): title by @user in <pr-url>`` bullets by conventional-commit type.
|
||||
Lines without that shape (headings, "New Contributors" entries) are skipped, not counted as other.
|
||||
"""
|
||||
return Counter(
|
||||
_PREFIX_BUCKETS.get(match.group(1).lower(), "other_updates")
|
||||
for line in body.splitlines()
|
||||
if (match := _RELEASE_BULLET_PATTERN.match(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["new_features"],
|
||||
bug_fixes=counts["bug_fixes"],
|
||||
other_updates=counts["other_updates"],
|
||||
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
|
||||
) -> LatestReleaseInfo | LatestReleaseUnavailable:
|
||||
cached: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY)
|
||||
if isinstance(cached, (LatestReleaseInfo, LatestReleaseUnavailable)):
|
||||
return cached
|
||||
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: _AsyncGetClient = Depends(_default_client),
|
||||
cache: InMemoryCache = Depends(_default_cache),
|
||||
) -> 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)
|
||||
if isinstance(result, LatestReleaseUnavailable):
|
||||
verbose_proxy_logger.warning("LiteLLM: latest release info unavailable: %s", result.reason)
|
||||
return None
|
||||
return result
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
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,
|
||||
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": 2,
|
||||
"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
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
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"] == 2
|
||||
|
||||
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").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())
|
||||
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()
|
||||
first = await get_latest_release_info(client=client, cache=cache)
|
||||
second = await get_latest_release_info(client=client, cache=cache)
|
||||
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)
|
||||
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()
|
||||
first = await get_latest_release_info(client=client, cache=cache)
|
||||
second = await get_latest_release_info(client=client, cache=cache)
|
||||
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())
|
||||
assert isinstance(result, LatestReleaseUnavailable)
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
|
@ -12,6 +12,7 @@ import { DebugWarningBanner } from "@/components/DebugWarningBanner";
|
|||
import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner";
|
||||
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";
|
||||
|
|
@ -115,6 +116,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|||
<NoRedisWarningBanner accessToken={accessToken} />
|
||||
<LicenseExpiryBanner accessToken={accessToken} />
|
||||
<UserBanner accessToken={accessToken} />
|
||||
<UpgradeBanner accessToken={accessToken} />
|
||||
<main className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<AgentControlPlaneView />
|
||||
</main>
|
||||
|
|
@ -134,6 +136,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
|
|||
<NoRedisWarningBanner accessToken={accessToken} />
|
||||
<LicenseExpiryBanner accessToken={accessToken} />
|
||||
<UserBanner accessToken={accessToken} />
|
||||
<UpgradeBanner accessToken={accessToken} />
|
||||
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
108
ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx
Normal file
108
ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
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", () => {
|
||||
expect(describeRelease({ ...RELEASE, new_features: 1, bug_fixes: 1, other_updates: 1 })).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(<UpgradeBannerView currentVersion={undefined} latestRelease={RELEASE} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
const { container: noRelease } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={null} />);
|
||||
expect(noRelease).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing when the running version is up to date or ahead", () => {
|
||||
const { container } = render(<UpgradeBannerView currentVersion="1.103.0" latestRelease={RELEASE} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
const { container: ahead } = render(<UpgradeBannerView currentVersion="1.104.0-dev.1" latestRelease={RELEASE} />);
|
||||
expect(ahead).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows the latest version, the stat line, and the current version when behind", () => {
|
||||
render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
|
||||
const alert = screen.getByRole("alert");
|
||||
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(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
const { container } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("reappears once a newer release ships after a dismissal", () => {
|
||||
const { unmount } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
unmount();
|
||||
|
||||
render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={{ ...RELEASE, version: "1.104.0" }} />);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("The latest version is v1.104.0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UpgradeBanner", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("feeds both hooks the access token and renders from their data", () => {
|
||||
vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { litellm_version: "1.102.0" } } as any);
|
||||
vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: RELEASE } as any);
|
||||
render(<UpgradeBanner accessToken="token" />);
|
||||
expect(useHealthReadinessDetails).toHaveBeenCalledWith("token");
|
||||
expect(useLatestReleaseInfo).toHaveBeenCalledWith("token");
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("The latest version is v1.103.0");
|
||||
});
|
||||
|
||||
it("renders nothing when the release endpoint returns null", () => {
|
||||
vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { litellm_version: "1.102.0" } } as any);
|
||||
vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: null } as any);
|
||||
const { container } = render(<UpgradeBanner accessToken="token" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
77
ui/litellm-dashboard/src/components/UpgradeBanner.tsx
Normal file
77
ui/litellm-dashboard/src/components/UpgradeBanner.tsx
Normal file
|
|
@ -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<UpgradeBannerViewProps> = ({ currentVersion, latestRelease }) => {
|
||||
const [locallyDismissed, setLocallyDismissed] = useState(false);
|
||||
|
||||
if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dismissKey = `${DISMISS_KEY_PREFIX}${latestRelease.version}`;
|
||||
if (locallyDismissed || getLocalStorageItem(dismissKey) === "true") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setLocalStorageItem(dismissKey, "true");
|
||||
setLocallyDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert variant="info" className="rounded-none border-x-0 border-t-0">
|
||||
<ArrowUpCircle className="size-4" aria-hidden />
|
||||
<AlertTitle>
|
||||
The latest version is{" "}
|
||||
<a href={latestRelease.release_url} target="_blank" rel="noopener noreferrer" className="underline">
|
||||
v{latestRelease.version}
|
||||
</a>
|
||||
: {describeRelease(latestRelease)}
|
||||
</AlertTitle>
|
||||
<AlertDescription>Your current version is v{currentVersion}</AlertDescription>
|
||||
<AlertAction>
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={handleClose}>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</AlertAction>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export const UpgradeBanner: React.FC<UpgradeBannerProps> = ({ accessToken }) => {
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
const { data: latestRelease } = useLatestReleaseInfo(accessToken);
|
||||
return <UpgradeBannerView currentVersion={healthData?.litellm_version} latestRelease={latestRelease} />;
|
||||
};
|
||||
54
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
54
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -5109,6 +5109,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;
|
||||
|
|
@ -28352,6 +28373,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 */
|
||||
|
|
@ -47428,6 +47462,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;
|
||||
|
|
|
|||
53
ui/litellm-dashboard/src/utils/versionUtils.test.ts
Normal file
53
ui/litellm-dashboard/src/utils/versionUtils.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
23
ui/litellm-dashboard/src/utils/versionUtils.ts
Normal file
23
ui/litellm-dashboard/src/utils/versionUtils.ts
Normal file
|
|
@ -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;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue