From bb89296adf5dbf16b4fbc7f99fc5730b4933dd76 Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Tue, 10 Nov 2026 18:43:06 -0800 Subject: [PATCH] fix(proxy): keep the upgrade notice off the request path and count area-labelled release notes (#51027) * fix(proxy): keep the upgrade notice off the request path and count area-labelled release notes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore(proxy): regenerate dashboard API types and read the release cache through one typed helper Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): respond 502 from /get/latest_release_info when no release note entry is countable Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): respond 502 when the release fetch failed and announce a release with no countable notes without counts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): re-ask for the latest release while the proxy is still fetching it and keep fetch diagnostics out of the 502 body Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep the known release on screen while the proxy refetches it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): answer 202 while the release is being fetched so the dashboard polls only then Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): name the release query options to stay inside the lint budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): return the 202 as a response value instead of mutating the injected response Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): fetch the release on the background client so its 502 never reaches the error handler Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../latest_release_endpoints.py | 56 ++++++-- .../test_latest_release_endpoints.py | 126 ++++++++++++++++-- .../useLatestReleaseInfo.test.ts | 76 +++++++++++ .../latestRelease/useLatestReleaseInfo.ts | 49 +++++-- .../NotificationsBell.test.tsx | 15 +++ .../NotificationsBell/NotificationsBell.tsx | 27 ++-- ui/litellm-dashboard/src/i18n/locales/en.json | 1 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 20 ++- 8 files changed, 323 insertions(+), 47 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.test.ts diff --git a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py index 4bb17fd290e..49f90997879 100644 --- a/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py @@ -9,7 +9,8 @@ from types import MappingProxyType from typing import Annotated, Final, Literal, Protocol, TypeAlias import httpx -from fastapi import APIRouter, Depends +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status +from fastapi.responses import JSONResponse from pydantic import ValidationError from litellm._logging import verbose_proxy_logger @@ -29,11 +30,13 @@ 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)?\S") +_AREA_BULLET_PATTERN: Final = re.compile(r"^-\s+\*\*[^*]+\*\*\s+([A-Za-z]+)") _NEW_CONTRIBUTOR_PATTERN: Final = re.compile(r"^\*\s+@\S+ made their first contribution\b") _CHANGELOG_PAGE_PATTERN: Final = re.compile(rf"{re.escape(CHANGELOG_URL)}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"}) +_VERB_BUCKETS: Final[Mapping[str, _Bucket]] = MappingProxyType({"added": "new_features", "fixed": "bug_fixes"}) class LatestReleaseInfo(LiteLLMBaseModel): @@ -97,6 +100,9 @@ def _default_release_check_enabled() -> bool: def _bucket_for(line: str) -> _Bucket | None: if _NEW_CONTRIBUTOR_PATTERN.match(line) is not None: return None + area_match: Final = _AREA_BULLET_PATTERN.match(line) + if area_match is not None: + return _VERB_BUCKETS.get(area_match.group(1).lower(), "other_updates") match: Final = _RELEASE_BULLET_PATTERN.match(line) if match is None: return None @@ -105,7 +111,8 @@ def _bucket_for(line: str) -> _Bucket | None: def count_release_bullets(body: str) -> Mapping[_Bucket, int]: - """Bucket release-note bullets by conventional-commit type or ``other_updates``.""" + """Bucket release-note bullets by conventional-commit type (``* fix(x): ...``) or by the verb after the area label + (``- **Area:** Fixed ...``), falling back to ``other_updates``.""" return MappingProxyType(Counter(bucket for line in body.splitlines() if (bucket := _bucket_for(line)) is not None)) @@ -200,15 +207,20 @@ async def fetch_latest_release(client: _AsyncGetClient) -> LatestReleaseInfo | L ) +def _cached_release(cache: InMemoryCache) -> LatestReleaseInfo | LatestReleaseUnavailable | None: + cached: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY) + return cached if isinstance(cached, (LatestReleaseInfo, LatestReleaseUnavailable)) else None + + async def get_latest_release_info( client: _AsyncGetClient, cache: InMemoryCache, fetch_lock: asyncio.Lock ) -> LatestReleaseInfo | LatestReleaseUnavailable: - cached: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY) - if isinstance(cached, (LatestReleaseInfo, LatestReleaseUnavailable)): + cached: Final = _cached_release(cache) + if cached is not None: return cached async with fetch_lock: - cached_after_lock: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY) - if isinstance(cached_after_lock, (LatestReleaseInfo, LatestReleaseUnavailable)): + cached_after_lock: Final = _cached_release(cache) + if cached_after_lock is not None: return cached_after_lock result: Final = await fetch_latest_release(client) ttl: Final = ( @@ -220,28 +232,46 @@ async def get_latest_release_info( return result +async def refresh_latest_release_info(client: _AsyncGetClient, cache: InMemoryCache, fetch_lock: asyncio.Lock) -> None: + 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) + + @router.get( "/get/latest_release_info", tags=["UI Settings"], dependencies=[Depends(user_api_key_auth)], response_model=LatestReleaseInfo | None, + responses={ + 202: {"description": "The release is being fetched in the background, so ask again shortly", "model": None}, + 502: {"description": "The changelog or GitHub could not be reached or answered with something unusable"}, + }, ) async def latest_release_info( + background_tasks: BackgroundTasks, client: Annotated[_AsyncGetClient, Depends(_default_client)], cache: Annotated[InMemoryCache, Depends(_default_cache)], fetch_lock: Annotated[asyncio.Lock, Depends(_default_fetch_lock)], release_check_enabled: Annotated[bool, Depends(_default_release_check_enabled)], -) -> LatestReleaseInfo | None: +) -> LatestReleaseInfo | JSONResponse | None: """ Newest stable release on the LiteLLM changelog, linked to its changelog page, with the PR count of its whole minor line (e.g. v1.105.0 + v1.105.1) from GitHub split into new features, bug fixes and other updates. - Returns null when the changelog or GitHub can't be reached so the dashboard upgrade banner simply doesn't render. + Never waits on the changelog or GitHub. It answers from the cache and, when the cache is empty or expired, responds + 202 with null and fetches after the response is sent, so a later call returns the release. Responds 502 once that + fetch has failed because the changelog or GitHub couldn't be reached or answered with something unusable, so the dashboard + upgrade notice simply doesn't render. The three counts are all zero when the release notes list no change in a + format this proxy can count. Set LITELLM_DISABLE_LATEST_RELEASE_CHECK=true to return null without making any outbound request. """ if not release_check_enabled: return None - 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 - return result + cached: Final = _cached_release(cache) + if isinstance(cached, LatestReleaseInfo): + return cached + if isinstance(cached, LatestReleaseUnavailable): + raise HTTPException(status_code=502, detail="Latest release info unavailable") + if not fetch_lock.locked(): + background_tasks.add_task(refresh_latest_release_info, client, cache, fetch_lock) + return JSONResponse(status_code=status.HTTP_202_ACCEPTED, content=None) diff --git a/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py index b5a41c31810..e3d9e66c208 100644 --- a/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py +++ b/tests/unit/proxy/ui_crud_endpoints/test_latest_release_endpoints.py @@ -121,6 +121,53 @@ def _override_dependencies(client: _ByUrl, cache: InMemoryCache, role: LitellmUs app.dependency_overrides[_default_fetch_lock] = lambda: asyncio.Lock() +class _AsgiGet: + """One GET sent straight to the ASGI app, so the response body is readable while the app call is still running.""" + + def __init__(self, path: str) -> None: + self._chunks: list[bytes] = [] + self._sent = asyncio.Event() + self.task = asyncio.create_task( + app( + { + "type": "http", + "asgi": {"version": "3.0"}, + "http_version": "1.1", + "method": "GET", + "scheme": "http", + "path": path, + "raw_path": path.encode(), + "query_string": b"", + "headers": [(b"host", b"proxy")], + "client": ("127.0.0.1", 1), + "server": ("proxy", 80), + }, + self._receive, + self._send, + ) + ) + + async def _receive(self) -> dict[str, object]: + return {"type": "http.request", "body": b"", "more_body": False} + + async def _send(self, message: dict[str, object]) -> None: + if message["type"] != "http.response.body": + return + body = message.get("body", b"") + assert isinstance(body, bytes) + self._chunks.append(body) + if not message.get("more_body", False): + self._sent.set() + + @property + def finished(self) -> bool: + return self.task.done() + + async def body(self) -> bytes: + await self._sent.wait() + return b"".join(self._chunks) + + @pytest.fixture def http_client(): yield TestClient(app) @@ -152,6 +199,18 @@ class TestCountReleaseBullets: == 0 ) + def test_buckets_area_labelled_bullets_by_their_leading_verb(self): + counts = count_release_bullets( + "### Changes\n\n" + "- **OpenAI:** Fixed empty `api_key` sending the admin key (#1)\n" + "- **AWS Bedrock:** fixed Converse `service_tier` (#2)\n" + "- **MCP:** Added per-server timeouts (#3)\n" + "- **Routing and fallbacks:** Changed the default cooldown (#4)\n" + "- **UI:** Moved the upgrade notice into the bell (#5)\n" + "- plain dash line that is not a change entry\n" + ) + assert (counts["new_features"], counts["bug_fixes"], counts["other_updates"]) == (1, 2, 2) + 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) @@ -387,19 +446,62 @@ class TestPatchReleaseAggregatesMinorLine: class TestLatestReleaseInfoEndpoint: - def test_returns_release_stats_for_authenticated_user(self, http_client): + def test_first_request_returns_null_and_the_next_returns_the_fetched_release(self, http_client): _override_dependencies(_minor_release(), _fresh_cache(), LitellmUserRoles.INTERNAL_USER) - response = http_client.get("/get/latest_release_info") - assert response.status_code == 200 - assert response.json() == EXPECTED_INFO + first = http_client.get("/get/latest_release_info") + assert (first.status_code, first.json()) == (202, None) + second = http_client.get("/get/latest_release_info") + assert (second.status_code, second.json()) == (200, EXPECTED_INFO) - def test_returns_null_when_the_changelog_is_unreachable(self, http_client): + @pytest.mark.asyncio + async def test_responds_while_the_changelog_fetch_is_still_hanging(self): + release = asyncio.Event() + + class _HangingClient(_ByUrl): + async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: + self.calls.append((url, timeout)) + await release.wait() + raise httpx.ConnectTimeout("blackholed") + + client: Final = _HangingClient({}) + _override_dependencies(client, _fresh_cache(), LitellmUserRoles.PROXY_ADMIN) + lock: Final = asyncio.Lock() + app.dependency_overrides[_default_fetch_lock] = lambda: lock + try: + requests: Final = [_AsgiGet("/get/latest_release_info") for _ in range(3)] + bodies: Final = [await asyncio.wait_for(request.body(), timeout=5) for request in requests] + still_fetching: Final = [request for request in requests if not request.finished] + release.set() + await asyncio.gather(*(request.task for request in requests)) + finally: + release.set() + for dependency in (user_api_key_auth, _default_client, _default_cache, _default_fetch_lock): + app.dependency_overrides.pop(dependency, None) + assert bodies == [b"null", b"null", b"null"] + assert len(still_fetching) == 1, "exactly one request should still be running the hanging fetch" + assert client.urls == [CHANGELOG_FEED_URL] + + def test_responds_502_once_the_changelog_fetch_has_failed(self, http_client): + client: Final = _ByUrl({CHANGELOG_FEED_URL: httpx.ConnectError("boom")}) + _override_dependencies(client, _fresh_cache(), LitellmUserRoles.PROXY_ADMIN) + first: Final = http_client.get("/get/latest_release_info") + assert (first.status_code, first.json()) == (202, None) + later: Final = [http_client.get("/get/latest_release_info") for _ in range(2)] + assert [response.status_code for response in later] == [502, 502] + assert later[0].json() == {"detail": "Latest release info unavailable"} + assert len(client.calls) == 1 + + def test_release_with_no_countable_notes_is_still_announced_with_zero_counts(self, http_client): + uncountable: Final = _github_response(payload=_release("v1.102.0", "## Changes\n\n1. Reworked the router\n")) _override_dependencies( - _ByUrl({CHANGELOG_FEED_URL: httpx.ConnectError("boom")}), _fresh_cache(), LitellmUserRoles.PROXY_ADMIN + _minor_release(**{_tag_url("v1.102.0"): uncountable}), _fresh_cache(), LitellmUserRoles.PROXY_ADMIN + ) + http_client.get("/get/latest_release_info") + response: Final = http_client.get("/get/latest_release_info") + assert (response.status_code, response.json()) == ( + 200, + {**EXPECTED_INFO, "new_features": 0, "bug_fixes": 0, "other_updates": 0}, ) - response = http_client.get("/get/latest_release_info") - assert response.status_code == 200 - assert response.json() is None @pytest.mark.parametrize("name", ["LITELLM_DISABLE_LATEST_RELEASE_CHECK", "LITELLM_OFFLINE_MODE"]) @pytest.mark.parametrize( @@ -416,6 +518,8 @@ class TestLatestReleaseInfoEndpoint: monkeypatch.setenv(name, flag) client = _minor_release() _override_dependencies(client, _fresh_cache(), LitellmUserRoles.PROXY_ADMIN) + first = http_client.get("/get/latest_release_info") + assert first.status_code == (200 if expected_body is None else 202) response = http_client.get("/get/latest_release_info") assert response.status_code == 200 assert response.json() == expected_body @@ -424,8 +528,8 @@ class TestLatestReleaseInfoEndpoint: def test_repeated_requests_reuse_cache(self, http_client): client = _minor_release() _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 + bodies = [http_client.get("/get/latest_release_info").json() for _ in range(3)] + assert bodies == [None, EXPECTED_INFO, EXPECTED_INFO] assert len(client.calls) == 2 def test_rejects_unauthenticated_requests(self, monkeypatch): diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.test.ts new file mode 100644 index 00000000000..ed76b5524be --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerErrorHandler } from "@/lib/http/runtime"; +import { + PENDING_RELEASE_POLL_MS, + fetchLatestRelease, + keepKnownRelease, + pendingReleasePollMs, +} from "./useLatestReleaseInfo"; + +const RELEASE = { version: "1.103.0", new_features: 1, bug_fixes: 2, other_updates: 3, release_url: "https://x" }; + +describe("pendingReleasePollMs", () => { + it("asks again soon while the proxy is still fetching the release", () => { + const state = { status: "success", data: { release: null, pending: true } }; + expect(pendingReleasePollMs(state)).toBe(PENDING_RELEASE_POLL_MS); + }); + + it("keeps asking while a known release is shown and the proxy is refetching it", () => { + const state = { status: "success", data: { release: RELEASE, pending: true } }; + expect(pendingReleasePollMs(state)).toBe(PENDING_RELEASE_POLL_MS); + }); + + it("stops once the release arrives", () => { + expect(pendingReleasePollMs({ status: "success", data: { release: RELEASE, pending: false } })).toBe(false); + }); + + it("stops once the proxy reports the fetch failed", () => { + expect(pendingReleasePollMs({ status: "error", data: { release: null, pending: true } })).toBe(false); + }); + + it("does not ask again when the release check is turned off", () => { + expect(pendingReleasePollMs({ status: "success", data: { release: null, pending: false } })).toBe(false); + }); +}); + +describe("keepKnownRelease", () => { + it("keeps showing the known release while the proxy refetches it", () => { + const kept = keepKnownRelease({ release: RELEASE, pending: false }, { release: null, pending: true }); + expect(kept).toEqual({ release: RELEASE, pending: true }); + }); + + it("takes a newer release over the known one", () => { + const newer = { release: { ...RELEASE, version: "1.104.0" }, pending: false }; + expect(keepKnownRelease({ release: RELEASE, pending: true }, newer)).toBe(newer); + }); + + it("drops the known release when the release check has been turned off", () => { + const off = { release: null, pending: false }; + expect(keepKnownRelease({ release: RELEASE, pending: false }, off)).toBe(off); + }); + + it("records a first answer as is", () => { + const first = { release: null, pending: true }; + expect(keepKnownRelease(undefined, first)).toBe(first); + }); +}); + +describe("fetchLatestRelease", () => { + afterEach(() => { + vi.unstubAllGlobals(); + registerErrorHandler(() => {}); + }); + + it("keeps a failed release fetch away from the dashboard error handler", async () => { + const onError = vi.fn(); + registerErrorHandler(onError); + const unavailable = new Response(JSON.stringify({ detail: "Latest release info unavailable" }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(unavailable)); + + await expect(fetchLatestRelease()).rejects.toMatchObject({ status: 502 }); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts index 5186baa605c..7a3dcf92dac 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo.ts @@ -1,16 +1,41 @@ -import { $api } from "@/lib/http/api"; +import { useQuery } from "@tanstack/react-query"; +import { backgroundFetchClient } from "@/lib/http/api"; import type { components } from "@/lib/http/schema"; +import { createQueryKeys } from "../common/queryKeysFactory"; 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, - }, - ); +export type LatestReleaseAnswer = { release: LatestReleaseInfo | null; pending: boolean }; + +export const PENDING_RELEASE_POLL_MS = 5 * 1000; + +export const latestReleaseKeys = createQueryKeys("latestRelease"); + +export const pendingReleasePollMs = (state: { status: string; data?: LatestReleaseAnswer }) => + state.status === "success" && state.data?.pending ? PENDING_RELEASE_POLL_MS : false; + +export const keepKnownRelease = (known: unknown, answer: unknown) => { + const previous = known as LatestReleaseAnswer | undefined; + const next = answer as LatestReleaseAnswer; + return next.pending && previous?.release ? { ...next, release: previous.release } : next; +}; + +export const fetchLatestRelease = async (): Promise => { + const { data, response } = await backgroundFetchClient.GET("/get/latest_release_info"); + return { release: data ?? null, pending: response.status === 202 }; +}; + +export const useLatestReleaseInfo = (accessToken: string | null | undefined) => { + const options = { + queryKey: latestReleaseKeys.all, + queryFn: fetchLatestRelease, + enabled: Boolean(accessToken), + staleTime: 60 * 60 * 1000, + retry: false, + refetchInterval: (query: { state: { status: string; data?: LatestReleaseAnswer } }) => + pendingReleasePollMs(query.state), + structuralSharing: keepKnownRelease, + }; + const { data, isError } = useQuery(options); + return { data: isError ? undefined : data?.release }; +}; diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx index 3b0eec6feec..3110d39a75d 100644 --- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx @@ -75,6 +75,21 @@ describe("NotificationsBellView", () => { expect(announcement("LiteLLM v1.103.0 is available")).toHaveTextContent("1 new feature, 1 fix, and 1 other update"); }); + it("leaves the counts out when the release notes yielded none", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + await user.click(bell(2)); + const upgrade = announcement("LiteLLM v1.103.0 is available"); + expect(upgrade).toHaveTextContent("Your current version is v1.102.0"); + expect(upgrade).not.toHaveTextContent("0 new features"); + expect(upgrade).not.toHaveTextContent("other update"); + }); + it.each([ ["the running version is unknown", undefined, RELEASE], ["the latest release is unavailable", "1.102.0", null], diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx index 05e3881373e..20cb15a552d 100644 --- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx @@ -45,12 +45,22 @@ const autoRouterAnnouncement = (): Announcement => ({ href: AUTO_ROUTER_DOCS_URL, }); -const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string => - [ - i18n.t("shell.notifications.release.newFeatures", { count: new_features }), - i18n.t("shell.notifications.release.fixes", { count: bug_fixes }), - i18n.t("shell.notifications.release.otherUpdates", { count: other_updates }), - ].join(", "); +const describeUpgrade = ( + currentVersion: string, + { new_features, bug_fixes, other_updates }: LatestReleaseInfo, +): string => { + if (new_features + bug_fixes + other_updates === 0) { + return i18n.t("shell.notifications.upgrade.descriptionWithoutCounts", { current: currentVersion }); + } + return i18n.t("shell.notifications.upgrade.description", { + summary: [ + i18n.t("shell.notifications.release.newFeatures", { count: new_features }), + i18n.t("shell.notifications.release.fixes", { count: bug_fixes }), + i18n.t("shell.notifications.release.otherUpdates", { count: other_updates }), + ].join(", "), + current: currentVersion, + }); +}; const upgradeAnnouncement = ( currentVersion: string | null | undefined, @@ -62,10 +72,7 @@ const upgradeAnnouncement = ( return { readKey: `litellm:upgradeBannerDismissed:${latestRelease.version}`, title: i18n.t("shell.notifications.upgrade.title", { version: latestRelease.version }), - description: i18n.t("shell.notifications.upgrade.description", { - summary: describeRelease(latestRelease), - current: currentVersion, - }), + description: describeUpgrade(currentVersion, latestRelease), linkLabel: i18n.t("shell.notifications.upgrade.link"), href: latestRelease.release_url, }; diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index 113c77e1916..25bf2817e27 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -8208,6 +8208,7 @@ "upgrade": { "title": "LiteLLM v{{version}} is available", "description": "{{summary}}. Your current version is v{{current}}", + "descriptionWithoutCounts": "Your current version is v{{current}}", "link": "View release notes" }, "announcement": { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4f946a4be89..f5f75e65c83 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -6077,7 +6077,11 @@ export interface paths { * Latest Release Info * @description Newest stable release on the LiteLLM changelog, linked to its changelog page, with the PR count of its whole * minor line (e.g. v1.105.0 + v1.105.1) from GitHub split into new features, bug fixes and other updates. - * Returns null when the changelog or GitHub can't be reached so the dashboard upgrade banner simply doesn't render. + * Never waits on the changelog or GitHub. It answers from the cache and, when the cache is empty or expired, responds + * 202 with null and fetches after the response is sent, so a later call returns the release. Responds 502 once that + * fetch has failed because the changelog or GitHub couldn't be reached or answered with something unusable, so the dashboard + * upgrade notice simply doesn't render. The three counts are all zero when the release notes list no change in a + * format this proxy can count. * Set LITELLM_DISABLE_LATEST_RELEASE_CHECK=true to return null without making any outbound request. */ get: operations["latest_release_info_get_latest_release_info_get"]; @@ -63074,6 +63078,20 @@ export interface operations { "application/json": components["schemas"]["LatestReleaseInfo"] | null; }; }; + /** @description The release is being fetched in the background, so ask again shortly */ + 202: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The changelog or GitHub could not be reached or answered with something unusable */ + 502: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; get_mcp_semantic_filter_settings_get_mcp_semantic_filter_settings_get: {