fix(proxy): count the whole minor line in the upgrade notice for patch releases (#46942)

* fix(proxy): count the whole minor line in the upgrade notice for patch releases

A patch release's notes only list its backports, so v1.105.1 showed
"0 new features, 1 fix, and 0 other updates" even though upgrading from
v1.104.x brings everything in v1.105.0 too. When the latest release is a
patch, also fetch the recent release list and sum the stable releases on
the same major.minor line (skipping rc/dev prereleases and drafts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(proxy): use tuples for release lists and sync the dashboard API types

Satisfies the LIT001 type-discipline gate and regenerates the
latest_release_info description in schema.d.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(proxy): cover the bad release-list payload and non-semver latest tag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(proxy): type the release-line Counters so basedpyright sees no unknowns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(proxy): fetch earlier patches of the minor line by tag

Reading the first page of /releases could miss the line's .0 release when
prereleases push it off the page, and one unrelated release with a null
body failed validation for the whole list. Fetch each earlier patch tag
directly instead (v1.105.0 .. v1.105.N-1), skip tags with no stable
release, and treat a null release body as empty notes.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Misbah Syed 2026-10-17 15:49:57 -07:00 • committed by GitHub
parent 16645cc963
commit 8b442f00fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 183 additions and 15 deletions

View file

@ -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)

View file

@ -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)

View file

@ -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"];