mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-23 05:34:41 +00:00
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:
parent
16645cc963
commit
8b442f00fc
3 changed files with 183 additions and 15 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
3
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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"];
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue