mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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>
This commit is contained in:
parent
6d532f2549
commit
4036b769a9
4 changed files with 139 additions and 39 deletions
|
|
@ -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 <pr-url>`` 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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ describe("UpgradeBannerView", () => {
|
|||
|
||||
it("shows the latest version, the stat line, and the current version when behind", () => {
|
||||
render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
|
||||
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(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
unmount();
|
||||
|
||||
const { container } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
|
||||
|
|
@ -80,7 +80,7 @@ describe("UpgradeBannerView", () => {
|
|||
unmount();
|
||||
|
||||
render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={{ ...RELEASE, version: "1.104.0" }} />);
|
||||
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<ReturnType<typeof useHealthReadinessDetails>> as ReturnType<typeof useHealthReadinessDetails>;
|
||||
const latestReleaseResult = { data: RELEASE } as Partial<ReturnType<typeof useLatestReleaseInfo>> as ReturnType<
|
||||
typeof useLatestReleaseInfo
|
||||
>;
|
||||
vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
|
||||
vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
|
||||
render(<UpgradeBanner accessToken="token" />);
|
||||
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<ReturnType<typeof useHealthReadinessDetails>> as ReturnType<typeof useHealthReadinessDetails>;
|
||||
const latestReleaseResult = { data: null } as Partial<ReturnType<typeof useLatestReleaseInfo>> as ReturnType<
|
||||
typeof useLatestReleaseInfo
|
||||
>;
|
||||
vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
|
||||
vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
|
||||
const { container } = render(<UpgradeBanner accessToken="token" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ export const UpgradeBannerView: React.FC<UpgradeBannerViewProps> = ({ currentVer
|
|||
};
|
||||
|
||||
return (
|
||||
<Alert variant="info" className="rounded-none border-x-0 border-t-0">
|
||||
<Alert role="status" variant="info" className="rounded-none border-x-0 border-t-0">
|
||||
<ArrowUpCircle className="size-4" aria-hidden />
|
||||
<AlertTitle>
|
||||
The latest version is{" "}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue