From 4036b769a952ace55b802d317e335881a921d3b4 Mon Sep 17 00:00:00 2001 From: kerry Date: Sat, 19 Sep 2026 00:22:15 +0000 Subject: [PATCH] fix(proxy): count unprefixed release bullets and coalesce concurrent latest release fetches Unprefixed release bullets now count as other_updates, concurrent cache misses share one upstream GitHub request through an injected asyncio.Lock, and the dashboard upgrade banner is announced as status rather than alert Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../latest_release_endpoints.py | 55 +++++++----- .../test_latest_release_endpoints.py | 89 ++++++++++++++++--- .../src/components/UpgradeBanner.test.tsx | 32 +++++-- .../src/components/UpgradeBanner.tsx | 2 +- 4 files changed, 139 insertions(+), 39 deletions(-) diff --git a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py index 4a01728dfe1..61124e9f27e 100644 --- a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py @@ -1,3 +1,4 @@ +import asyncio import re from collections import Counter from collections.abc import Awaitable, Mapping @@ -21,7 +22,8 @@ LATEST_RELEASE_CACHE_TTL_SECONDS: Final = 60 * 60 LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS: Final = 5 * 60 LATEST_RELEASE_CACHE_KEY: Final = "latest_release_info" -_RELEASE_BULLET_PATTERN: Final = re.compile(r"^\*\s+([A-Za-z]+)(\([^)]*\))?!?:\s") +_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") _Bucket = Literal["new_features", "bug_fixes", "other_updates"] _PREFIX_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"feat": "new_features", "fix": "bug_fixes"}) @@ -51,6 +53,7 @@ class _AsyncGetClient(Protocol): _latest_release_cache: Final = InMemoryCache(max_size_in_memory=1, default_ttl=LATEST_RELEASE_CACHE_TTL_SECONDS) +_latest_release_fetch_lock: Final = asyncio.Lock() def _default_client() -> _AsyncGetClient: @@ -64,16 +67,23 @@ def _default_cache() -> InMemoryCache: return _latest_release_cache +def _default_fetch_lock() -> asyncio.Lock: + return _latest_release_fetch_lock + + +def _bucket_for(line: str) -> _Bucket | None: + if _NEW_CONTRIBUTOR_PATTERN.match(line) is not None: + return None + match: Final = _RELEASE_BULLET_PATTERN.match(line) + if match is None: + return None + prefix: Final = match.group(1) + return "other_updates" if prefix is None else _PREFIX_BUCKETS.get(prefix.lower(), "other_updates") + + def count_release_bullets(body: str) -> Counter[_Bucket]: - """ - Bucket a release body's ``* type(scope): title by @user in `` bullets by conventional-commit type. - Lines without that shape (headings, "New Contributors" entries) are skipped, not counted as other. - """ - return Counter( - _PREFIX_BUCKETS.get(match.group(1).lower(), "other_updates") - for line in body.splitlines() - if (match := _RELEASE_BULLET_PATTERN.match(line)) is not None - ) + """Bucket release-note bullets by conventional-commit type or ``other_updates``.""" + return Counter(bucket for line in body.splitlines() if (bucket := _bucket_for(line)) is not None) def parse_latest_release(response: httpx.Response) -> LatestReleaseInfo | LatestReleaseUnavailable: @@ -102,19 +112,23 @@ async def fetch_latest_release(client: _AsyncGetClient) -> LatestReleaseInfo | L async def get_latest_release_info( - client: _AsyncGetClient, cache: InMemoryCache + client: _AsyncGetClient, cache: InMemoryCache, fetch_lock: asyncio.Lock ) -> LatestReleaseInfo | LatestReleaseUnavailable: cached: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY) if isinstance(cached, (LatestReleaseInfo, LatestReleaseUnavailable)): return cached - result: Final = await fetch_latest_release(client) - ttl: Final = ( - LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS - if isinstance(result, LatestReleaseUnavailable) - else LATEST_RELEASE_CACHE_TTL_SECONDS - ) - cache.set_cache(LATEST_RELEASE_CACHE_KEY, result, ttl=ttl) - return result + async with fetch_lock: + cached_after_lock: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY) + if isinstance(cached_after_lock, (LatestReleaseInfo, LatestReleaseUnavailable)): + return cached_after_lock + result: Final = await fetch_latest_release(client) + ttl: Final = ( + LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS + if isinstance(result, LatestReleaseUnavailable) + else LATEST_RELEASE_CACHE_TTL_SECONDS + ) + cache.set_cache(LATEST_RELEASE_CACHE_KEY, result, ttl=ttl) + return result @router.get( @@ -126,12 +140,13 @@ async def get_latest_release_info( async def latest_release_info( client: Annotated[_AsyncGetClient, Depends(_default_client)], cache: Annotated[InMemoryCache, Depends(_default_cache)], + 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. 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) + result: Final = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock) if isinstance(result, LatestReleaseUnavailable): verbose_proxy_logger.warning("LiteLLM: latest release info unavailable: %s", result.reason) return None diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py index 0bbcdfe317d..c966b8b7135 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py @@ -1,3 +1,4 @@ +import asyncio import json import time from typing import Final @@ -19,6 +20,7 @@ from litellm.proxy.ui_crud_endpoints.latest_release_endpoints import ( LatestReleaseUnavailable, _default_cache, _default_client, + _default_fetch_lock, count_release_bullets, get_latest_release_info, ) @@ -48,7 +50,7 @@ EXPECTED_INFO: Final = { "version": "1.102.0", "new_features": 2, "bug_fixes": 2, - "other_updates": 2, + "other_updates": 4, "release_url": SAMPLE_RELEASE["html_url"], } @@ -81,6 +83,7 @@ def _override_dependencies(client: _RecordingClient, cache: InMemoryCache, role: app.dependency_overrides[user_api_key_auth] = auth app.dependency_overrides[_default_client] = lambda: client app.dependency_overrides[_default_cache] = lambda: cache + app.dependency_overrides[_default_fetch_lock] = lambda: asyncio.Lock() @pytest.fixture @@ -89,6 +92,7 @@ def http_client(): app.dependency_overrides.pop(user_api_key_auth, None) app.dependency_overrides.pop(_default_client, None) app.dependency_overrides.pop(_default_cache, None) + app.dependency_overrides.pop(_default_fetch_lock, None) class TestCountReleaseBullets: @@ -96,11 +100,21 @@ class TestCountReleaseBullets: counts = count_release_bullets(SAMPLE_BODY) assert counts["new_features"] == 2 assert counts["bug_fixes"] == 2 - assert counts["other_updates"] == 2 + assert counts["other_updates"] == 4 + + def test_unprefixed_bullets_count_as_other_updates(self): + counts = count_release_bullets("* Litellm dev 09 08 2026 by @f in https://x/pull/7\n") + assert (counts["new_features"], counts["bug_fixes"], counts["other_updates"]) == (0, 0, 1) def test_ignores_non_bullet_lines_and_contributor_entries(self): assert ( - sum(count_release_bullets("## What's Changed\n\n* @x made their first contribution in url\n").values()) == 0 + sum( + count_release_bullets( + "## What's Changed\n\n* @x made their first contribution in url\n" + "\n**Full Changelog**: https://github.com/BerriAI/litellm/compare/v1...v2\n" + ).values() + ) + == 0 ) def test_empty_body_yields_zero_counts(self): @@ -112,7 +126,7 @@ class TestGetLatestReleaseInfo: @pytest.mark.asyncio async def test_fetches_and_parses_github_release(self): client = _RecordingClient([_github_response()]) - result = await get_latest_release_info(client=client, cache=_fresh_cache()) + 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)] @@ -121,15 +135,18 @@ class TestGetLatestReleaseInfo: async def test_second_call_within_ttl_does_not_refetch(self): client = _RecordingClient([_github_response()]) cache = _fresh_cache() - first = await get_latest_release_info(client=client, cache=cache) - second = await get_latest_release_info(client=client, cache=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 @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) + await get_latest_release_info( + client=_RecordingClient([_github_response()]), 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 @@ -137,8 +154,9 @@ class TestGetLatestReleaseInfo: async def test_failure_is_cached_briefly_so_github_is_not_hammered(self): client = _RecordingClient([httpx.ConnectError("boom")]) cache = _fresh_cache() - first = await get_latest_release_info(client=client, cache=cache) - second = await get_latest_release_info(client=client, cache=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 isinstance(first, LatestReleaseUnavailable) assert first == second assert len(client.calls) == 1 @@ -159,9 +177,60 @@ class TestGetLatestReleaseInfo: ids=["rate_limited", "server_error", "missing_fields", "not_json"], ) async def test_bad_github_responses_are_unavailable(self, response: httpx.Response): - result = await get_latest_release_info(client=_RecordingClient([response]), cache=_fresh_cache()) + result = await get_latest_release_info( + client=_RecordingClient([response]), cache=_fresh_cache(), fetch_lock=asyncio.Lock() + ) assert isinstance(result, LatestReleaseUnavailable) + @pytest.mark.asyncio + async def test_concurrent_misses_share_one_fetch(self): + event = asyncio.Event() + + class _BlockingClient(_RecordingClient): + async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: + self.calls.append((url, timeout)) + await event.wait() + return _github_response() + + client = _BlockingClient([]) + cache = _fresh_cache() + fetch_lock = asyncio.Lock() + tasks = [ + asyncio.create_task(get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)) + for _ in range(5) + ] + await asyncio.sleep(0) + await asyncio.sleep(0) + event.set() + results = await asyncio.gather(*tasks) + expected: Final = LatestReleaseInfo.model_validate(EXPECTED_INFO) + assert results == [expected] * 5 + assert len(client.calls) == 1 + + @pytest.mark.asyncio + async def test_failure_under_lock_is_also_coalesced(self): + event = asyncio.Event() + + class _FailingBlockingClient(_RecordingClient): + 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([]) + cache = _fresh_cache() + fetch_lock = asyncio.Lock() + tasks = [ + asyncio.create_task(get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)) + for _ in range(5) + ] + await asyncio.sleep(0) + await asyncio.sleep(0) + event.set() + results = await asyncio.gather(*tasks) + assert all(isinstance(result, LatestReleaseUnavailable) for result in results) + assert len(client.calls) == 1 + class TestLatestReleaseInfoEndpoint: def test_returns_release_stats_for_authenticated_user(self, http_client): diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx index 6aa384600c8..831030fee80 100644 --- a/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx +++ b/ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx @@ -58,7 +58,7 @@ describe("UpgradeBannerView", () => { it("shows the latest version, the stat line, and the current version when behind", () => { render(); - const alert = screen.getByRole("alert"); + const alert = screen.getByRole("status"); expect(alert).toHaveTextContent("The latest version is v1.103.0: 12 new features, 30 fixes, and 8 other updates"); expect(alert).toHaveTextContent("Your current version is v1.102.0"); expect(screen.getByRole("link", { name: "v1.103.0" })).toHaveAttribute("href", RELEASE.release_url); @@ -67,7 +67,7 @@ describe("UpgradeBannerView", () => { it("dismissing hides the banner and keeps it hidden on remount for the same release", () => { const { unmount } = render(); fireEvent.click(screen.getByRole("button", { name: "Close" })); - expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); unmount(); const { container } = render(); @@ -80,7 +80,7 @@ describe("UpgradeBannerView", () => { unmount(); render(); - expect(screen.getByRole("alert")).toHaveTextContent("The latest version is v1.104.0"); + expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0"); }); }); @@ -89,18 +89,34 @@ describe("UpgradeBanner", () => { localStorage.clear(); }); + afterEach(() => { + localStorage.clear(); + }); + it("feeds both hooks the access token and renders from their data", () => { - vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { litellm_version: "1.102.0" } } as any); - vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: RELEASE } as any); + const healthReadinessResult = { + data: { litellm_version: "1.102.0" }, + } as Partial> as ReturnType; + const latestReleaseResult = { data: RELEASE } as Partial> as ReturnType< + typeof useLatestReleaseInfo + >; + vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult); + vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult); render(); expect(useHealthReadinessDetails).toHaveBeenCalledWith("token"); expect(useLatestReleaseInfo).toHaveBeenCalledWith("token"); - expect(screen.getByRole("alert")).toHaveTextContent("The latest version is v1.103.0"); + expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.103.0"); }); it("renders nothing when the release endpoint returns null", () => { - vi.mocked(useHealthReadinessDetails).mockReturnValue({ data: { litellm_version: "1.102.0" } } as any); - vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: null } as any); + const healthReadinessResult = { + data: { litellm_version: "1.102.0" }, + } as Partial> as ReturnType; + const latestReleaseResult = { data: null } as Partial> as ReturnType< + typeof useLatestReleaseInfo + >; + vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult); + vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult); const { container } = render(); expect(container).toBeEmptyDOMElement(); }); diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx index cc57223eaff..d990c5112a4 100644 --- a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx +++ b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx @@ -51,7 +51,7 @@ export const UpgradeBannerView: React.FC = ({ currentVer }; return ( - + The latest version is{" "}