From 46922bf1206bf51d39cbd82ba64934d3d76014dd Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Fri, 23 Oct 2026 11:18:38 -0700 Subject: [PATCH] fix(proxy): take the upgrade notice from the changelog instead of GitHub releases (#47998) The Admin UI upgrade notice read GitHub's latest release, which the release pipeline sets on its own, so admins were offered releases the changelog had not announced yet and sent to the auto-generated GitHub release page. The notice now reads the newest stable release from the docs changelog feed and links to its changelog page. GitHub release notes are still used for the feature and fix counts of that version's minor line. Co-authored-by: yucheng-berri <293926326+yucheng-berri@users.noreply.github.com> --- .../latest_release_endpoints.py | 68 ++++-- .../test_latest_release_endpoints.py | 217 ++++++++++-------- .../NotificationsBell.test.tsx | 2 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 6 +- 4 files changed, 181 insertions(+), 112 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py index 7411f1b4947..ef2a5f88e4e 100644 --- a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py @@ -1,5 +1,6 @@ import asyncio import re +import xml.etree.ElementTree as ET from collections import Counter from collections.abc import Awaitable, Mapping from dataclasses import dataclass @@ -17,7 +18,8 @@ from litellm.types.llms.base import LiteLLMBaseModel router: Final = APIRouter() -LATEST_RELEASE_URL: Final = "https://api.github.com/repos/BerriAI/litellm/releases/latest" +CHANGELOG_URL: Final = "https://docs.litellm.ai/release_notes/" +CHANGELOG_FEED_URL: Final = f"{CHANGELOG_URL}rss.xml" RELEASE_BY_TAG_URL: Final = "https://api.github.com/repos/BerriAI/litellm/releases/tags/{tag}" LATEST_RELEASE_FETCH_TIMEOUT_SECONDS: Final = 5 LATEST_RELEASE_CACHE_TTL_SECONDS: Final = 60 * 60 @@ -26,7 +28,7 @@ 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") -_STABLE_TAG_PATTERN: Final = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") +_CHANGELOG_PAGE_PATTERN: Final = re.compile(rf"{re.escape(CHANGELOG_URL)}v(\d+)\.(\d+)\.(\d+)/") _Bucket: TypeAlias = Literal["new_features", "bug_fixes", "other_updates"] _PREFIX_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"feat": "new_features", "fix": "bug_fixes"}) @@ -45,6 +47,16 @@ class LatestReleaseUnavailable: reason: str +@dataclass(frozen=True, slots=True) +class ChangelogEntry: + version: tuple[int, int, int] + url: str + + @property + def tag(self) -> str: + return "v" + ".".join(str(part) for part in self.version) + + class _GitHubRelease(LiteLLMBaseModel): tag_name: str html_url: str @@ -91,9 +103,18 @@ def count_release_bullets(body: str) -> Mapping[_Bucket, int]: return MappingProxyType(Counter(bucket for line in body.splitlines() if (bucket := _bucket_for(line)) is not None)) -def _stable_version(tag_name: str) -> tuple[int, int, int] | None: - match: Final = _STABLE_TAG_PATTERN.match(tag_name) - return None if match is None else (int(match.group(1)), int(match.group(2)), int(match.group(3))) +def _changelog_entry(link: str) -> ChangelogEntry | None: + match: Final = _CHANGELOG_PAGE_PATTERN.match(link) + if match is None: + return None + return ChangelogEntry(version=(int(match.group(1)), int(match.group(2)), int(match.group(3))), url=link) + + +def latest_changelog_entry(feed: bytes) -> ChangelogEntry | None: + """The highest stable release with a page in the changelog feed. Release candidate pages don't count.""" + links: Final = (link.text or "" for link in ET.fromstring(feed).iterfind("channel/item/link")) + entries: Final = (entry for link in links if (entry := _changelog_entry(link)) is not None) + return max(entries, key=lambda entry: entry.version, default=None) def earlier_patch_tags(version: tuple[int, int, int]) -> tuple[str, ...]: @@ -102,7 +123,9 @@ def earlier_patch_tags(version: tuple[int, int, int]) -> tuple[str, ...]: return tuple(f"v{major}.{minor}.{earlier}" for earlier in range(patch)) -def summarize_release_line(latest: _GitHubRelease, earlier: tuple[_GitHubRelease, ...]) -> LatestReleaseInfo: +def summarize_release_line( + entry: ChangelogEntry, latest: _GitHubRelease, earlier: tuple[_GitHubRelease, ...] +) -> LatestReleaseInfo: """A patch release's notes only list its backports, so count the whole minor line it belongs to.""" counts: Final = sum( (Counter[_Bucket](count_release_bullets(release.body or "")) for release in (latest, *earlier)), @@ -113,7 +136,7 @@ def summarize_release_line(latest: _GitHubRelease, earlier: tuple[_GitHubRelease new_features=counts.get("new_features", 0), bug_fixes=counts.get("bug_fixes", 0), other_updates=counts.get("other_updates", 0), - release_url=latest.html_url, + release_url=entry.url, ) @@ -137,25 +160,38 @@ async def _fetch_earlier_release(client: _AsyncGetClient, tag: str) -> _GitHubRe return release +async def _fetch_changelog_entry(client: _AsyncGetClient) -> ChangelogEntry | LatestReleaseUnavailable: + response: Final = await client.get(CHANGELOG_FEED_URL, timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS) + if response.status_code != 200: + return LatestReleaseUnavailable(reason=f"Changelog feed responded with status {response.status_code}") + try: + entry: Final = latest_changelog_entry(response.content) + except ET.ParseError as e: + return LatestReleaseUnavailable(reason=f"Changelog feed was not valid XML: {e}") + return LatestReleaseUnavailable(reason="Changelog feed lists no stable release") if entry is None else entry + + async def fetch_latest_release(client: _AsyncGetClient) -> LatestReleaseInfo | LatestReleaseUnavailable: try: + entry: Final = await _fetch_changelog_entry(client) + if isinstance(entry, LatestReleaseUnavailable): + return entry latest: Final = parse_github_release( - await client.get(LATEST_RELEASE_URL, timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS) + await client.get(RELEASE_BY_TAG_URL.format(tag=entry.tag), timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS) ) if isinstance(latest, LatestReleaseUnavailable): return latest - version: Final = _stable_version(latest.tag_name) - if version is None: - return summarize_release_line(latest, ()) earlier: Final = await asyncio.gather( - *(_fetch_earlier_release(client, tag) for tag in earlier_patch_tags(version)) + *(_fetch_earlier_release(client, tag) for tag in earlier_patch_tags(entry.version)) ) except httpx.HTTPError as e: return LatestReleaseUnavailable(reason=f"{type(e).__name__}: {e}") unavailable: Final = next((release for release in earlier if isinstance(release, LatestReleaseUnavailable)), None) if unavailable is not None: return unavailable - return summarize_release_line(latest, tuple(release for release in earlier if isinstance(release, _GitHubRelease))) + return summarize_release_line( + entry, latest, tuple(release for release in earlier if isinstance(release, _GitHubRelease)) + ) async def get_latest_release_info( @@ -190,9 +226,9 @@ async def latest_release_info( fetch_lock: Annotated[asyncio.Lock, Depends(_default_fetch_lock)], ) -> LatestReleaseInfo | None: """ - Latest stable LiteLLM GitHub release with the PR count of its whole minor line (e.g. v1.105.0 + v1.105.1) - 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. + Newest stable release on the LiteLLM changelog, linked to its changelog page, with the PR count of its whole + minor line (e.g. v1.105.0 + v1.105.1) from GitHub split into new features, bug fixes and other updates. + Returns null when the changelog or 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): diff --git a/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py index 25f1301dd9d..47f81fb55d1 100644 --- a/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py +++ b/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py @@ -12,10 +12,11 @@ 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 ( + CHANGELOG_FEED_URL, + CHANGELOG_URL, LATEST_RELEASE_CACHE_KEY, LATEST_RELEASE_CACHE_TTL_SECONDS, LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS, - LATEST_RELEASE_URL, RELEASE_BY_TAG_URL, LatestReleaseInfo, LatestReleaseUnavailable, @@ -49,37 +50,68 @@ SAMPLE_RELEASE: Final = { "html_url": "https://github.com/BerriAI/litellm/releases/tag/v1.102.0", "body": SAMPLE_BODY, } + + +def _changelog_page(folder: str) -> str: + return f"{CHANGELOG_URL}{folder}/{folder.removeprefix('v').replace('.', '-')}" + + +def _feed(*folders: str) -> httpx.Response: + items: Final = "".join( + f"{folder}{_changelog_page(folder)}" for folder in folders + ) + return httpx.Response(200, content=f'{items}'.encode()) + + EXPECTED_INFO: Final = { "version": "1.102.0", "new_features": 2, "bug_fixes": 2, "other_updates": 4, - "release_url": SAMPLE_RELEASE["html_url"], + "release_url": _changelog_page("v1.102.0"), } -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 _tag_url(tag: str) -> str: + return RELEASE_BY_TAG_URL.format(tag=tag) + + +class _ByUrl: + """Answers each URL from a fixed map; unknown URLs are 404, like a tag with no release.""" + + def __init__(self, routes: dict[str, httpx.Response | Exception]) -> None: + self._routes = routes + 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._routes.get(url, httpx.Response(404, content=b"{}")) + if isinstance(outcome, Exception): + raise outcome + return outcome + + @property + def urls(self) -> list[str]: + return [url for url, _ in self.calls] + + +def _minor_release_routes(**overrides: httpx.Response | Exception) -> dict[str, httpx.Response | Exception]: + return {CHANGELOG_FEED_URL: _feed("v1.102.0"), _tag_url("v1.102.0"): _github_response(), **overrides} + + +def _minor_release(**overrides: httpx.Response | Exception) -> _ByUrl: + return _ByUrl(_minor_release_routes(**overrides)) + + 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: +def _override_dependencies(client: _ByUrl, cache: InMemoryCache, role: LitellmUserRoles) -> None: async def auth() -> UserAPIKeyAuth: return UserAPIKeyAuth(api_key="sk-test", user_id="test-user", user_role=role) @@ -127,35 +159,68 @@ class TestCountReleaseBullets: class TestGetLatestReleaseInfo: @pytest.mark.asyncio - async def test_fetches_and_parses_github_release(self): - client = _RecordingClient([_github_response()]) + async def test_announces_the_changelog_release_and_links_its_changelog_page(self): + client = _minor_release() 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)] + assert client.calls == [(CHANGELOG_FEED_URL, 5), (_tag_url("v1.102.0"), 5)] + + @pytest.mark.asyncio + async def test_github_release_missing_from_the_changelog_is_not_announced(self): + automated: Final = _github_response(payload=_release("v1.103.0", "* feat(proxy): automated release\n")) + result = await _info(_minor_release(**{_tag_url("v1.103.0"): automated})) + assert isinstance(result, LatestReleaseInfo) + assert result.model_dump() == EXPECTED_INFO + + @pytest.mark.asyncio + async def test_highest_stable_version_wins_regardless_of_feed_order(self): + client = _minor_release(**{CHANGELOG_FEED_URL: _feed("v1.101.6", "v1.103.0rc1", "v1.102.0", "v1.100.9")}) + result = await _info(client) + assert isinstance(result, LatestReleaseInfo) + assert result.model_dump() == EXPECTED_INFO + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "feed", + [ + httpx.Response(503, content=_feed("v1.102.0").content), + httpx.Response(200, content=b"not a feed"), + _feed("v1.103.0rc1", "v1.103.0-dev.1"), + httpx.Response( + 200, + content=f"https://example.com/?{_changelog_page('v9.0.0')}" + "https://example.com/release_notes/v9.0.0/x".encode(), + ), + ], + ids=["server_error", "not_xml", "only_prereleases", "foreign_links"], + ) + async def test_bad_changelog_feeds_are_unavailable(self, feed: httpx.Response): + client = _minor_release(**{CHANGELOG_FEED_URL: feed}) + result = await _info(client) + assert isinstance(result, LatestReleaseUnavailable) + assert client.urls == [CHANGELOG_FEED_URL] @pytest.mark.asyncio async def test_second_call_within_ttl_does_not_refetch(self): - client = _RecordingClient([_github_response()]) + client = _minor_release() 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 + assert len(client.calls) == 2 @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() - ) + await get_latest_release_info(client=_minor_release(), 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")]) + client = _ByUrl({CHANGELOG_FEED_URL: httpx.ConnectError("boom")}) cache = _fresh_cache() fetch_lock = asyncio.Lock() first = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock) @@ -172,30 +237,29 @@ class TestGetLatestReleaseInfo: @pytest.mark.parametrize( "response", [ + _github_response(status=404, payload={"message": "Not Found"}), _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"), + httpx.ConnectError("boom"), ], - ids=["rate_limited", "server_error", "missing_fields", "not_json"], + ids=["no_release_for_tag", "rate_limited", "server_error", "missing_fields", "not_json", "unreachable"], ) - 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() - ) + async def test_bad_github_responses_are_unavailable(self, response: httpx.Response | Exception): + result = await _info(_minor_release(**{_tag_url("v1.102.0"): response})) assert isinstance(result, LatestReleaseUnavailable) @pytest.mark.asyncio async def test_concurrent_misses_share_one_fetch(self): event = asyncio.Event() - class _BlockingClient(_RecordingClient): + class _BlockingClient(_ByUrl): async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: - self.calls.append((url, timeout)) await event.wait() - return _github_response() + return await super().get(url, timeout=timeout) - client = _BlockingClient([]) + client = _BlockingClient(_minor_release_routes()) cache = _fresh_cache() fetch_lock = asyncio.Lock() tasks = [ @@ -208,19 +272,19 @@ class TestGetLatestReleaseInfo: results = await asyncio.gather(*tasks) expected: Final = LatestReleaseInfo.model_validate(EXPECTED_INFO) assert results == [expected] * 5 - assert len(client.calls) == 1 + assert len(client.calls) == 2 @pytest.mark.asyncio async def test_failure_under_lock_is_also_coalesced(self): event = asyncio.Event() - class _FailingBlockingClient(_RecordingClient): + class _FailingBlockingClient(_ByUrl): 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([]) + client = _FailingBlockingClient({}) cache = _fresh_cache() fetch_lock = asyncio.Lock() tasks = [ @@ -245,40 +309,23 @@ def _release(tag: str, body: str | None, *, prerelease: bool = False) -> dict[st } -def _tag_url(tag: str) -> str: - return RELEASE_BY_TAG_URL.format(tag=tag) - - -class _GitHubByUrl: - """Answers each GitHub URL from a fixed map; unknown URLs are 404, like a tag with no release.""" - - def __init__(self, routes: dict[str, httpx.Response | Exception]) -> None: - self._routes = routes - self.urls: list[str] = [] - - async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: - self.urls.append(url) - outcome = self._routes.get(url, httpx.Response(404, content=b"{}")) - if isinstance(outcome, Exception): - raise outcome - return outcome - - PATCH_RELEASE: Final = _release("v1.102.2", "* fix(proxy): second backport by @k in https://x/pull/10\n") FIRST_PATCH: Final = _release("v1.102.1", "* fix(proxy): first backport by @k in https://x/pull/9\n") -def _minor_line(**overrides: httpx.Response | Exception) -> dict[str, httpx.Response | Exception]: - routes: dict[str, httpx.Response | Exception] = { - LATEST_RELEASE_URL: _github_response(payload=PATCH_RELEASE), - _tag_url("v1.102.0"): _github_response(payload=SAMPLE_RELEASE), - _tag_url("v1.102.1"): _github_response(payload=FIRST_PATCH), - } - routes.update({_tag_url(tag): outcome for tag, outcome in overrides.items()}) - return routes +def _minor_line(**overrides: httpx.Response | Exception) -> _ByUrl: + return _ByUrl( + { + CHANGELOG_FEED_URL: _feed("v1.102.2", "v1.101.6"), + _tag_url("v1.102.2"): _github_response(payload=PATCH_RELEASE), + _tag_url("v1.102.0"): _github_response(payload=SAMPLE_RELEASE), + _tag_url("v1.102.1"): _github_response(payload=FIRST_PATCH), + **{_tag_url(tag): outcome for tag, outcome in overrides.items()}, + } + ) -async def _info(client: _GitHubByUrl) -> LatestReleaseInfo | LatestReleaseUnavailable: +async def _info(client: _ByUrl) -> LatestReleaseInfo | LatestReleaseUnavailable: return await get_latest_release_info(client=client, cache=_fresh_cache(), fetch_lock=asyncio.Lock()) @@ -289,7 +336,7 @@ class TestPatchReleaseAggregatesMinorLine: @pytest.mark.asyncio async def test_patch_release_counts_include_every_earlier_patch_on_its_line(self): - client = _GitHubByUrl(_minor_line()) + client = _minor_line() result = await _info(client) assert isinstance(result, LatestReleaseInfo) assert result.model_dump() == { @@ -297,23 +344,11 @@ class TestPatchReleaseAggregatesMinorLine: "new_features": 2, "bug_fixes": 4, "other_updates": 4, - "release_url": PATCH_RELEASE["html_url"], + "release_url": _changelog_page("v1.102.2"), } - assert sorted(client.urls) == sorted([LATEST_RELEASE_URL, _tag_url("v1.102.0"), _tag_url("v1.102.1")]) - - @pytest.mark.asyncio - async def test_minor_release_makes_a_single_request(self): - client = _RecordingClient([_github_response()]) - await get_latest_release_info(client=client, cache=_fresh_cache(), fetch_lock=asyncio.Lock()) - assert client.calls == [(LATEST_RELEASE_URL, 5)] - - @pytest.mark.asyncio - async def test_non_semver_latest_tag_counts_only_its_own_notes(self): - client = _GitHubByUrl({LATEST_RELEASE_URL: _github_response(payload=_release("v1.102.1-hotfix", "* fix: x\n"))}) - result = await _info(client) - assert isinstance(result, LatestReleaseInfo) - assert (result.new_features, result.bug_fixes, result.other_updates) == (0, 1, 0) - assert client.urls == [LATEST_RELEASE_URL] + assert sorted(client.urls) == sorted( + [CHANGELOG_FEED_URL, _tag_url("v1.102.2"), _tag_url("v1.102.0"), _tag_url("v1.102.1")] + ) @pytest.mark.asyncio @pytest.mark.parametrize( @@ -326,15 +361,13 @@ class TestPatchReleaseAggregatesMinorLine: ids=["never_published", "prerelease", "null_body"], ) async def test_missing_prerelease_or_empty_patch_adds_nothing(self, skipped: httpx.Response): - result = await _info(_GitHubByUrl(_minor_line(**{"v1.102.1": skipped}))) + result = await _info(_minor_line(**{"v1.102.1": skipped})) assert isinstance(result, LatestReleaseInfo) assert (result.new_features, result.bug_fixes, result.other_updates) == (2, 3, 4) @pytest.mark.asyncio async def test_latest_release_with_null_body_still_counts_its_line(self): - routes = _minor_line() - routes[LATEST_RELEASE_URL] = _github_response(payload=_release("v1.102.2", None)) - result = await _info(_GitHubByUrl(routes)) + result = await _info(_minor_line(**{"v1.102.2": _github_response(payload=_release("v1.102.2", None))})) assert isinstance(result, LatestReleaseInfo) assert (result.new_features, result.bug_fixes, result.other_updates) == (2, 3, 4) @@ -349,31 +382,31 @@ class TestPatchReleaseAggregatesMinorLine: ids=["unreachable", "rate_limited", "not_json"], ) async def test_earlier_patch_failure_is_unavailable(self, outcome: httpx.Response | Exception): - result = await _info(_GitHubByUrl(_minor_line(**{"v1.102.0": outcome}))) + result = await _info(_minor_line(**{"v1.102.0": outcome})) 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) + _override_dependencies(_minor_release(), _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): + def test_returns_null_when_the_changelog_is_unreachable(self, http_client): _override_dependencies( - _RecordingClient([httpx.ConnectError("boom")]), _fresh_cache(), LitellmUserRoles.PROXY_ADMIN + _ByUrl({CHANGELOG_FEED_URL: 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()]) + client = _minor_release() _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 + assert len(client.calls) == 2 def test_rejects_unauthenticated_requests(self, monkeypatch): monkeypatch.setattr("litellm.proxy.proxy_server.master_key", MASTER_KEY) diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx index e0c428a94a0..93d9b6018ac 100644 --- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx @@ -20,7 +20,7 @@ const RELEASE: LatestReleaseInfo = { new_features: 12, bug_fixes: 30, other_updates: 8, - release_url: "https://github.com/BerriAI/litellm/releases/tag/v1.103.0", + release_url: "https://docs.litellm.ai/release_notes/v1.103.0/v1-103-0", }; const bell = (unread: number) => diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4dd9e5d6073..f3821a2bb46 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -5865,9 +5865,9 @@ export interface paths { }; /** * Latest Release Info - * @description Latest stable LiteLLM GitHub release with the PR count of its whole minor line (e.g. v1.105.0 + v1.105.1) - * 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. + * @description Newest stable release on the LiteLLM changelog, linked to its changelog page, with the PR count of its whole + * minor line (e.g. v1.105.0 + v1.105.1) from GitHub split into new features, bug fixes and other updates. + * Returns null when the changelog or 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;