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;