diff --git a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py index 720b4dd8d32..7411f1b4947 100644 --- a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py @@ -18,6 +18,7 @@ from litellm.types.llms.base import LiteLLMBaseModel router: Final = APIRouter() LATEST_RELEASE_URL: Final = "https://api.github.com/repos/BerriAI/litellm/releases/latest" +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 LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS: Final = 5 * 60 @@ -25,6 +26,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+)$") _Bucket: TypeAlias = Literal["new_features", "bug_fixes", "other_updates"] _PREFIX_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"feat": "new_features", "fix": "bug_fixes"}) @@ -46,7 +48,9 @@ class LatestReleaseUnavailable: class _GitHubRelease(LiteLLMBaseModel): tag_name: str html_url: str - body: str + body: str | None + prerelease: bool = False + draft: bool = False class _AsyncGetClient(Protocol): @@ -87,29 +91,71 @@ 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 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) +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 earlier_patch_tags(version: tuple[int, int, int]) -> tuple[str, ...]: + """Tags of the patches that precede ``version`` on its minor line (v1.105.0 for v1.105.1).""" + major, minor, patch = version + return tuple(f"v{major}.{minor}.{earlier}" for earlier in range(patch)) + + +def summarize_release_line(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)), + Counter[_Bucket](), + ) return LatestReleaseInfo( - version=release.tag_name.removeprefix("v"), + version=latest.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, + release_url=latest.html_url, ) +def parse_github_release(response: httpx.Response) -> _GitHubRelease | LatestReleaseUnavailable: + if response.status_code != 200: + return LatestReleaseUnavailable(reason=f"GitHub responded with status {response.status_code}") + try: + return _GitHubRelease.model_validate_json(response.content) + except ValidationError as e: + return LatestReleaseUnavailable(reason=f"GitHub release payload was not the expected shape: {e}") + + +async def _fetch_earlier_release(client: _AsyncGetClient, tag: str) -> _GitHubRelease | LatestReleaseUnavailable | None: + """The stable release for ``tag``, or None when that patch was never published as one.""" + response: Final = await client.get(RELEASE_BY_TAG_URL.format(tag=tag), timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS) + if response.status_code == 404: + return None + release: Final = parse_github_release(response) + if isinstance(release, _GitHubRelease) and (release.prerelease or release.draft): + return None + return release + + async def fetch_latest_release(client: _AsyncGetClient) -> LatestReleaseInfo | LatestReleaseUnavailable: try: - response: Final = await client.get(LATEST_RELEASE_URL, timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS) + latest: Final = parse_github_release( + await client.get(LATEST_RELEASE_URL, 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)) + ) except httpx.HTTPError as e: return LatestReleaseUnavailable(reason=f"{type(e).__name__}: {e}") - return parse_latest_release(response) + 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))) async def get_latest_release_info( @@ -144,7 +190,8 @@ async def latest_release_info( 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. + 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. """ result: Final = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock) 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 ae0ac32c62d..25f1301dd9d 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 @@ -16,12 +16,14 @@ from litellm.proxy.ui_crud_endpoints.latest_release_endpoints import ( LATEST_RELEASE_CACHE_TTL_SECONDS, LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS, LATEST_RELEASE_URL, + RELEASE_BY_TAG_URL, LatestReleaseInfo, LatestReleaseUnavailable, _default_cache, _default_client, _default_fetch_lock, count_release_bullets, + earlier_patch_tags, get_latest_release_info, ) from tests._master_key import MASTER_KEY @@ -233,6 +235,124 @@ class TestGetLatestReleaseInfo: assert len(client.calls) == 1 +def _release(tag: str, body: str | None, *, prerelease: bool = False) -> dict[str, object]: + return { + "tag_name": tag, + "html_url": f"https://github.com/BerriAI/litellm/releases/tag/{tag}", + "body": body, + "prerelease": prerelease, + "draft": False, + } + + +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 + + +async def _info(client: _GitHubByUrl) -> LatestReleaseInfo | LatestReleaseUnavailable: + return await get_latest_release_info(client=client, cache=_fresh_cache(), fetch_lock=asyncio.Lock()) + + +class TestPatchReleaseAggregatesMinorLine: + def test_earlier_patch_tags_cover_the_whole_line(self): + assert earlier_patch_tags((1, 105, 2)) == ("v1.105.0", "v1.105.1") + assert earlier_patch_tags((1, 105, 0)) == () + + @pytest.mark.asyncio + async def test_patch_release_counts_include_every_earlier_patch_on_its_line(self): + client = _GitHubByUrl(_minor_line()) + result = await _info(client) + assert isinstance(result, LatestReleaseInfo) + assert result.model_dump() == { + "version": "1.102.2", + "new_features": 2, + "bug_fixes": 4, + "other_updates": 4, + "release_url": PATCH_RELEASE["html_url"], + } + 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] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "skipped", + [ + httpx.Response(404, content=b"{}"), + _github_response(payload=_release("v1.102.1", "* feat: rc only\n", prerelease=True)), + _github_response(payload=_release("v1.102.1", None)), + ], + 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}))) + 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)) + assert isinstance(result, LatestReleaseInfo) + assert (result.new_features, result.bug_fixes, result.other_updates) == (2, 3, 4) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "outcome", + [ + httpx.ConnectError("boom"), + _github_response(status=403, payload={"message": "rate limited"}), + httpx.Response(200, content=b"not json"), + ], + 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}))) + 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) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index f56fa87e93b..468811966de 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -5832,7 +5832,8 @@ export interface paths { }; /** * Latest Release Info - * @description Latest stable LiteLLM GitHub release with its PR count split into new features, bug fixes and other updates. + * @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. */ get: operations["latest_release_info_get_latest_release_info_get"];