mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
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>
This commit is contained in:
parent
917fd11966
commit
bb89296adf
8 changed files with 323 additions and 47 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<LatestReleaseAnswer> => {
|
||||
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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<NotificationsBellView
|
||||
currentVersion="1.102.0"
|
||||
latestRelease={{ ...RELEASE, new_features: 0, bug_fixes: 0, other_updates: 0 }}
|
||||
/>,
|
||||
);
|
||||
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],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
20
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
20
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue