Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ class LiteLLMRoutes(enum.Enum):
"/v1/models",
"/sso/get/ui_settings",
"/get/user_banner",
"/get/latest_release_info",
]

# NOTE: ROUTES ONLY FOR MASTER KEY - only the Master Key should be able to Reset Spend
Expand Down
4 changes: 4 additions & 0 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,9 @@ def generate_feedback_box():
)
from litellm.proxy.spend_tracking.spend_tracking_utils import get_logging_payload
from litellm.proxy.types_utils.utils import get_instance_fn
from litellm.proxy.ui_crud_endpoints.latest_release_endpoints import (
router as latest_release_endpoints_router,
)
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
router as ui_crud_endpoints_router,
)
Expand Down Expand Up @@ -19267,6 +19270,7 @@ async def get_routes():
app.include_router(rust_control_plane_router)
app.include_router(ui_crud_endpoints_router)
app.include_router(user_banner_endpoints_router)
app.include_router(latest_release_endpoints_router)
app.include_router(team_callback_router)
app.include_router(budget_management_router)
app.include_router(model_management_router)
Expand Down
153 changes: 153 additions & 0 deletions litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import asyncio
import re
from collections import Counter
from collections.abc import Awaitable, Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Annotated, Final, Literal, Protocol, TypeAlias

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)?\S")
_NEW_CONTRIBUTOR_PATTERN: Final = re.compile(r"^\*\s+@\S+ made their first contribution\b")

_Bucket: TypeAlias = 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)
_latest_release_fetch_lock: Final = asyncio.Lock()


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 _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) -> Mapping[_Bucket, int]:
"""Bucket release-note bullets by conventional-commit type or ``other_updates``."""
return MappingProxyType(Counter(bucket for line in body.splitlines() if (bucket := _bucket_for(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.get("new_features", 0),
bug_fixes=counts.get("bug_fixes", 0),
other_updates=counts.get("other_updates", 0),
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, fetch_lock: asyncio.Lock
) -> LatestReleaseInfo | LatestReleaseUnavailable:
cached: Final = cache.get_cache(LATEST_RELEASE_CACHE_KEY)
if isinstance(cached, (LatestReleaseInfo, LatestReleaseUnavailable)):
return cached
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(
"/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: 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, fetch_lock=fetch_lock)
if isinstance(result, LatestReleaseUnavailable):
verbose_proxy_logger.warning("LiteLLM: latest release info unavailable: %s", result.reason)
return None
return result
Comment thread
cursor[bot] marked this conversation as resolved.
27 changes: 27 additions & 0 deletions tests/test_litellm/proxy/auth/test_route_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,33 @@ def test_user_banner_read_open_to_non_admin_roles(role):
)


@pytest.mark.parametrize(
"role",
[
LitellmUserRoles.INTERNAL_USER.value,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
],
)
def test_latest_release_info_read_open_to_non_admin_roles(role): # test-quality-ok: allowed path returns None, not raising is the observable
user_obj = LiteLLM_UserTable(
user_id="test_user",
user_email="test@example.com",
user_role=role,
)
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=role)
request = MagicMock(spec=Request)
request.query_params = {}

RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=role,
route="/get/latest_release_info",
request=request,
valid_token=valid_token,
request_data={},
)


def test_user_banner_update_rejected_for_non_admin():
"""Publishing the banner stays admin-only at the route layer."""
user_obj = LiteLLM_UserTable(
Expand Down
Loading
Loading