Repository navigation
feat(ui): add upgrade banner with latest release changelog stats #40429
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
1a35831
feat(ui): add upgrade banner with latest release changelog stats
7edc795
fix(ui): fix CI failures on upgrade banner PR
6d532f2
Merge remote-tracking branch 'origin/main' into litellm_upgrade_banne…
kerry-berri 4036b76
fix(proxy): count unprefixed release bullets and coalesce concurrent …
kerry-berri 26bcc53
fix(proxy): allow latest release info and reset banner dismissal
kerry-berri a36a62a
Merge remote-tracking branch 'origin/main' into litellm_upgrade_banne…
kerry-berri ea04267
fix(proxy): return an immutable bucket mapping from count_release_bul…
kerry-berri 3777b0b
test(proxy): document why the release info route check asserts by not…
kerry-berri 4595d18
Merge remote-tracking branch 'origin/main' into pr40429
kerry-berri 9611af7
Merge remote-tracking branch 'origin/main' into litellm_upgrade_banne…
kerry-berri File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
138 changes: 138 additions & 0 deletions
138
litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| import re | ||
| from collections import Counter | ||
| from collections.abc import Awaitable, Mapping | ||
| from dataclasses import dataclass | ||
| from types import MappingProxyType | ||
| from typing import Final, Literal, Protocol | ||
|
|
||
| import httpx | ||
| from fastapi import APIRouter, Depends | ||
| from pydantic import BaseModel, ValidationError | ||
|
|
||
| from litellm._logging import verbose_proxy_logger | ||
| from litellm.caching.in_memory_cache import InMemoryCache | ||
| from litellm.proxy.auth.user_api_key_auth import user_api_key_auth | ||
|
|
||
| router: Final = APIRouter() | ||
|
|
||
| LATEST_RELEASE_URL: Final = "https://api.github.com/repos/BerriAI/litellm/releases/latest" | ||
| LATEST_RELEASE_FETCH_TIMEOUT_SECONDS: Final = 5 | ||
| 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") | ||
|
|
||
| _Bucket = Literal["new_features", "bug_fixes", "other_updates"] | ||
| _PREFIX_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"feat": "new_features", "fix": "bug_fixes"}) | ||
|
|
||
|
|
||
| class LatestReleaseInfo(BaseModel): | ||
| version: str | ||
| new_features: int | ||
| bug_fixes: int | ||
| other_updates: int | ||
| release_url: str | ||
|
|
||
|
|
||
| @dataclass(frozen=True, slots=True) | ||
| class LatestReleaseUnavailable: | ||
| reason: str | ||
|
|
||
|
|
||
| class _GitHubRelease(BaseModel): | ||
| tag_name: str | ||
| html_url: str | ||
| body: str | ||
|
|
||
|
|
||
| class _AsyncGetClient(Protocol): | ||
| def get(self, url: str, *, timeout: float | None = None) -> Awaitable[httpx.Response]: ... | ||
|
|
||
|
|
||
| _latest_release_cache: Final = InMemoryCache(max_size_in_memory=1, default_ttl=LATEST_RELEASE_CACHE_TTL_SECONDS) | ||
|
|
||
|
|
||
| def _default_client() -> _AsyncGetClient: | ||
| from litellm.llms.custom_httpx.http_handler import get_async_httpx_client | ||
| from litellm.types.llms.custom_http import httpxSpecialProvider | ||
|
|
||
| return get_async_httpx_client(llm_provider=httpxSpecialProvider.UI) | ||
|
|
||
|
|
||
| def _default_cache() -> InMemoryCache: | ||
| return _latest_release_cache | ||
|
|
||
|
|
||
| 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 | ||
| ) | ||
|
|
||
|
|
||
| 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) | ||
| return LatestReleaseInfo( | ||
| version=release.tag_name.removeprefix("v"), | ||
| new_features=counts["new_features"], | ||
| bug_fixes=counts["bug_fixes"], | ||
| other_updates=counts["other_updates"], | ||
| release_url=release.html_url, | ||
| ) | ||
|
|
||
|
|
||
| async def fetch_latest_release(client: _AsyncGetClient) -> LatestReleaseInfo | LatestReleaseUnavailable: | ||
| try: | ||
| response: Final = await client.get(LATEST_RELEASE_URL, timeout=LATEST_RELEASE_FETCH_TIMEOUT_SECONDS) | ||
| except httpx.HTTPError as e: | ||
| return LatestReleaseUnavailable(reason=f"{type(e).__name__}: {e}") | ||
| return parse_latest_release(response) | ||
|
|
||
|
|
||
| async def get_latest_release_info( | ||
| client: _AsyncGetClient, cache: InMemoryCache | ||
| ) -> 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) | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
|
||
| 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( | ||
| "/get/latest_release_info", | ||
| tags=["UI Settings"], # mutable-ok: FastAPI's route decorator only accepts a list | ||
| dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI's route decorator only accepts a list | ||
| response_model=LatestReleaseInfo | None, | ||
| ) | ||
| async def latest_release_info( | ||
| client: _AsyncGetClient = Depends(_default_client), | ||
| cache: InMemoryCache = Depends(_default_cache), | ||
| ) -> 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) | ||
| if isinstance(result, LatestReleaseUnavailable): | ||
| verbose_proxy_logger.warning("LiteLLM: latest release info unavailable: %s", result.reason) | ||
| return None | ||
| return result | ||
|
cursor[bot] marked this conversation as resolved.
Resolved
|
||
191 changes: 191 additions & 0 deletions
191
tests/test_litellm/proxy/ui_crud_endpoints/test_latest_release_endpoints.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,191 @@ | ||
| import json | ||
| import time | ||
| from typing import Final | ||
|
|
||
| import httpx | ||
| import pytest | ||
| from fastapi.testclient import TestClient | ||
|
|
||
| from litellm.caching.in_memory_cache import InMemoryCache | ||
| from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth | ||
| from litellm.proxy.auth.user_api_key_auth import user_api_key_auth | ||
| from litellm.proxy.proxy_server import app | ||
| from litellm.proxy.ui_crud_endpoints.latest_release_endpoints import ( | ||
| LATEST_RELEASE_CACHE_KEY, | ||
| LATEST_RELEASE_CACHE_TTL_SECONDS, | ||
| LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS, | ||
| LATEST_RELEASE_URL, | ||
| LatestReleaseInfo, | ||
| LatestReleaseUnavailable, | ||
| _default_cache, | ||
| _default_client, | ||
| count_release_bullets, | ||
| get_latest_release_info, | ||
| ) | ||
|
|
||
| SAMPLE_BODY: Final = """## What's Changed | ||
| * feat(proxy): add upgrade banner by @kerry in https://github.com/BerriAI/litellm/pull/1 | ||
| * fix(azure): retry on 429 by @a in https://github.com/BerriAI/litellm/pull/2 | ||
| * fix: handle empty body by @b in https://github.com/BerriAI/litellm/pull/3 | ||
| * Feat(ui)!: drop legacy theme by @c in https://github.com/BerriAI/litellm/pull/4 | ||
| * chore(deps): bump httpx by @d in https://github.com/BerriAI/litellm/pull/5 | ||
| * docs: fix typo by @e in https://github.com/BerriAI/litellm/pull/6 | ||
| * Litellm dev 09 08 2026 by @f in https://github.com/BerriAI/litellm/pull/7 | ||
| * refactor(router) : spaced colon does not match by @g in https://github.com/BerriAI/litellm/pull/8 | ||
|
|
||
| ## New Contributors | ||
| * @kerry made their first contribution in https://github.com/BerriAI/litellm/pull/1 | ||
|
|
||
| **Full Changelog**: https://github.com/BerriAI/litellm/compare/v1.101.0...v1.102.0 | ||
| """ | ||
|
|
||
| SAMPLE_RELEASE: Final = { | ||
| "tag_name": "v1.102.0", | ||
| "html_url": "https://github.com/BerriAI/litellm/releases/tag/v1.102.0", | ||
| "body": SAMPLE_BODY, | ||
| } | ||
| EXPECTED_INFO: Final = { | ||
| "version": "1.102.0", | ||
| "new_features": 2, | ||
| "bug_fixes": 2, | ||
| "other_updates": 2, | ||
| "release_url": SAMPLE_RELEASE["html_url"], | ||
| } | ||
|
|
||
|
|
||
| class _RecordingClient: | ||
| def __init__(self, outcomes: list[httpx.Response | Exception]) -> None: | ||
| self._outcomes = outcomes | ||
| self.calls: list[tuple[str, float | None]] = [] | ||
|
|
||
| async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: | ||
| self.calls.append((url, timeout)) | ||
| outcome = self._outcomes[min(len(self.calls) - 1, len(self._outcomes) - 1)] | ||
| if isinstance(outcome, Exception): | ||
| raise outcome | ||
| return outcome | ||
|
|
||
|
|
||
| def _github_response(status: int = 200, payload: object = SAMPLE_RELEASE) -> httpx.Response: | ||
| return httpx.Response(status, content=json.dumps(payload).encode()) | ||
|
|
||
|
|
||
| def _fresh_cache() -> InMemoryCache: | ||
| return InMemoryCache(max_size_in_memory=1, default_ttl=LATEST_RELEASE_CACHE_TTL_SECONDS) | ||
|
|
||
|
|
||
| def _override_dependencies(client: _RecordingClient, cache: InMemoryCache, role: LitellmUserRoles) -> None: | ||
| async def auth() -> UserAPIKeyAuth: | ||
| return UserAPIKeyAuth(api_key="sk-test", user_id="test-user", user_role=role) | ||
|
|
||
| app.dependency_overrides[user_api_key_auth] = auth | ||
| app.dependency_overrides[_default_client] = lambda: client | ||
| app.dependency_overrides[_default_cache] = lambda: cache | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def http_client(): | ||
| yield TestClient(app) | ||
| app.dependency_overrides.pop(user_api_key_auth, None) | ||
| app.dependency_overrides.pop(_default_client, None) | ||
| app.dependency_overrides.pop(_default_cache, None) | ||
|
|
||
|
|
||
| class TestCountReleaseBullets: | ||
| def test_buckets_by_conventional_commit_type(self): | ||
| counts = count_release_bullets(SAMPLE_BODY) | ||
| assert counts["new_features"] == 2 | ||
| assert counts["bug_fixes"] == 2 | ||
| assert counts["other_updates"] == 2 | ||
|
|
||
| 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 | ||
| ) | ||
|
|
||
| def test_empty_body_yields_zero_counts(self): | ||
| counts = count_release_bullets("") | ||
| assert (counts["new_features"], counts["bug_fixes"], counts["other_updates"]) == (0, 0, 0) | ||
|
|
||
|
|
||
| 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()) | ||
| assert isinstance(result, LatestReleaseInfo) | ||
| assert result.model_dump() == EXPECTED_INFO | ||
| assert client.calls == [(LATEST_RELEASE_URL, 5)] | ||
|
|
||
| @pytest.mark.asyncio | ||
| 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) | ||
| 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) | ||
| 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 | ||
|
|
||
| @pytest.mark.asyncio | ||
| 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) | ||
| assert isinstance(first, LatestReleaseUnavailable) | ||
| assert first == second | ||
| assert len(client.calls) == 1 | ||
| remaining = await cache.async_get_ttl(LATEST_RELEASE_CACHE_KEY) - time.time() | ||
| assert ( | ||
| LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS - 5 < remaining <= LATEST_RELEASE_UNAVAILABLE_CACHE_TTL_SECONDS | ||
| ) | ||
|
|
||
| @pytest.mark.asyncio | ||
| @pytest.mark.parametrize( | ||
| "response", | ||
| [ | ||
| _github_response(status=403, payload={"message": "rate limited"}), | ||
| _github_response(status=500, payload={}), | ||
| _github_response(payload={"tag_name": "v1.0.0"}), | ||
| httpx.Response(200, content=b"not json"), | ||
| ], | ||
| 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()) | ||
| 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) | ||
| response = http_client.get("/get/latest_release_info") | ||
| assert response.status_code == 200 | ||
| assert response.json() == EXPECTED_INFO | ||
|
|
||
| def test_returns_null_when_github_is_unreachable(self, http_client): | ||
| _override_dependencies( | ||
| _RecordingClient([httpx.ConnectError("boom")]), _fresh_cache(), LitellmUserRoles.PROXY_ADMIN | ||
| ) | ||
| response = http_client.get("/get/latest_release_info") | ||
| assert response.status_code == 200 | ||
| assert response.json() is None | ||
|
|
||
| def test_repeated_requests_reuse_cache(self, http_client): | ||
| client = _RecordingClient([_github_response()]) | ||
| _override_dependencies(client, _fresh_cache(), LitellmUserRoles.PROXY_ADMIN) | ||
| assert http_client.get("/get/latest_release_info").json() == EXPECTED_INFO | ||
| assert http_client.get("/get/latest_release_info").json() == EXPECTED_INFO | ||
| assert len(client.calls) == 1 | ||
|
|
||
| def test_rejects_unauthenticated_requests(self, monkeypatch): | ||
| monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-1234") | ||
| response = TestClient(app).get("/get/latest_release_info") | ||
| assert response.status_code in (401, 403) |
16 changes: 16 additions & 0 deletions
16
ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| import { $api } from "@/lib/http/api"; | ||
| import type { components } from "@/lib/http/schema"; | ||
|
|
||
| export type LatestReleaseInfo = components["schemas"]["LatestReleaseInfo"]; | ||
|
|
||
| export const useLatestReleaseInfo = (accessToken: string | null | undefined) => | ||
| $api.useQuery( | ||
| "get", | ||
| "/get/latest_release_info", | ||
| {}, | ||
| { | ||
| enabled: Boolean(accessToken), | ||
| staleTime: 60 * 60 * 1000, | ||
| retry: false, | ||
| }, | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.