Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,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 @@ -18368,6 +18371,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
138 changes: 138 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,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
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
)


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)
Comment thread
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
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
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)
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,
},
);
3 changes: 3 additions & 0 deletions ui/litellm-dashboard/src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner";
import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner";
import { UserBanner } from "@/components/UserBanner";
import { UpgradeBanner } from "@/components/UpgradeBanner";
import { uiHref } from "@/utils/uiHref";
import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext";
import { createApiClient } from "@/lib/http/client";
Expand Down Expand Up @@ -56,7 +57,7 @@
// targetOrigin is the configured plugin URL — no other origin receives it.
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe || !auth || auth.plugin !== activePluginName || !agentPlatformUrl) return;

Check warning on line 60 in ui/litellm-dashboard/src/app/(dashboard)/layout.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Boolean expression combines 4 conditions; extract it into a named variable
const send = () => {
iframe.contentWindow?.postMessage({ type: "litellm-auth", session_claim: auth.claim }, agentPlatformUrl);
};
Expand Down Expand Up @@ -115,6 +116,7 @@
<NoRedisWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<UserBanner accessToken={accessToken} />
<UpgradeBanner accessToken={accessToken} />
<main className="flex min-h-0 flex-1 overflow-hidden">
<AgentControlPlaneView />
</main>
Expand All @@ -134,6 +136,7 @@
<NoRedisWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<UserBanner accessToken={accessToken} />
<UpgradeBanner accessToken={accessToken} />
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
</div>
</div>
Expand Down
Loading
Loading