Merge remote-tracking branch 'origin/litellm_cost_shard_pricing_dimensions' into litellm_cost_shard_provider_wires

This commit is contained in:
kerry 2026-09-21 21:04:37 +00:00
commit 5034b96fb6
19 changed files with 1289 additions and 36 deletions

View file

@ -272,6 +272,19 @@ class BaseVideoConfig(ABC):
) -> VideoObject:
pass
async def async_transform_video_status_retrieve_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
custom_llm_provider: str | None = None,
) -> VideoObject:
"""Async transform video status retrieve response."""
return self.transform_video_status_retrieve_response(
raw_response=raw_response,
logging_obj=logging_obj,
custom_llm_provider=custom_llm_provider,
)
def transform_video_create_character_request(
self,
name: str,

View file

@ -8881,7 +8881,7 @@ class BaseLLMHTTPHandler:
url=url,
headers=headers,
)
return video_status_provider_config.transform_video_status_retrieve_response(
return await video_status_provider_config.async_transform_video_status_retrieve_response(
raw_response=response,
logging_obj=logging_obj,
custom_llm_provider=custom_llm_provider,

View file

@ -1,7 +1,7 @@
import math
import sys
import time
from collections.abc import Mapping
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, TypeAlias
@ -163,12 +163,127 @@ def _response_data(raw_response: httpx.Response) -> Mapping[str, object]:
return TypeAdapter(Mapping[str, object]).validate_python(raw_response.json())
def _response_data_or_none(raw_response: httpx.Response) -> Mapping[str, object] | None:
try:
return _response_data(raw_response)
except ValueError:
return None
def _detail_item_text(item: Mapping[str, object]) -> str | None:
message: Final[object] = item.get("msg")
if not isinstance(message, str):
return None
location: Final[object] = item.get("loc")
if isinstance(location, str) and location:
return f"{location}: {message}"
if isinstance(location, (list, tuple)):
location_parts: Final[tuple[str, ...]] = tuple(part for part in location if isinstance(part, str))
if location_parts:
return f"{'.'.join(location_parts)}: {message}"
return message
def _error_text(response_data: Mapping[str, object]) -> str | None:
detail: Final[object] = response_data.get("detail")
if isinstance(detail, str):
return detail
if isinstance(detail, list):
detail_items: Final[tuple[Mapping[str, object], ...]] = tuple(
item for item in detail if isinstance(item, Mapping)
)
detail_messages: Final[tuple[str, ...]] = tuple(
message for item in detail_items if (message := _detail_item_text(item)) is not None
)
if detail_messages:
return "; ".join(detail_messages)
error: Final[object] = response_data.get("error")
return error if isinstance(error, str) else None
def _result_error(raw_response: httpx.Response) -> str | None:
if raw_response.is_success:
return None
response_data: Final[Mapping[str, object] | None] = _response_data_or_none(raw_response)
error_text: Final[str | None] = _error_text(response_data) if response_data is not None else None
if error_text:
return error_text
response_text: Final[str] = raw_response.text
return response_text or f"fal.ai returned HTTP {raw_response.status_code}"
def _terminal_result_error(raw_response: httpx.Response) -> str | None:
if raw_response.status_code == 429 or raw_response.status_code >= 500:
return None
return _result_error(raw_response)
def _get_fal_ai_async_httpx_client() -> AsyncHTTPHandler:
return get_async_httpx_client(llm_provider=LlmProviders.FAL_AI)
def _response_string(response_data: Mapping[str, object], key: str, default: str = "") -> str:
value: Final[object] = response_data.get(key)
return value if isinstance(value, str) else default
def _result_request(
raw_response: httpx.Response,
response_data: Mapping[str, object],
) -> tuple[str, Mapping[str, str]] | None:
if _response_string(response_data, "status", "IN_QUEUE") != "COMPLETED":
return None
result_url: Final[str] = str(raw_response.request.url).removesuffix("/status")
result_headers: Final[Mapping[str, str]] = MappingProxyType(
{
key: value
for key, value in (
("Authorization", raw_response.request.headers.get("Authorization")),
("Content-Type", raw_response.request.headers.get("Content-Type")),
)
if value is not None
}
)
return result_url, result_headers
def _status_video_object(
response_data: Mapping[str, object],
raw_response: httpx.Response,
custom_llm_provider: str | None,
result_error: str | None,
) -> VideoObject:
raw_status: Final[str] = _response_string(response_data, "status", "IN_QUEUE")
status: Final[str] = _STATUS_MAP.get(raw_status, "queued")
status_error: Final[str | None] = _error_text(response_data)
error: Final[str | None] = result_error if result_error is not None else status_error
provider: Final[str] = custom_llm_provider or _FAL_AI_PROVIDER
model_path: Final[str | None] = _model_path_from_request_url(raw_response)
request_id: Final[str] = _response_string(response_data, "request_id") or (
_request_id_from_request_url(raw_response) or ""
)
return VideoObject(
id=encode_video_id_with_provider(request_id, provider, model_path),
object="video",
status="failed" if error else status,
created_at=0,
model=model_path,
error=(
{"code": "fal_error", "message": error} if error else None # mutable-ok: VideoObject requires a dict
),
)
class FalAIVideoConfig(BaseVideoConfig):
def __init__(
self,
sync_client_factory: Callable[[], HTTPHandler] = _get_httpx_client,
async_client_factory: Callable[[], AsyncHTTPHandler] = _get_fal_ai_async_httpx_client,
) -> None:
super().__init__()
self._sync_client_factory: Final = sync_client_factory
self._async_client_factory: Final = async_client_factory
def get_supported_openai_params(self, model: str) -> _SupportedParams:
supported_params: Final[_SupportedParams] = [ # mutable-ok: BaseVideoConfig requires a list
"model",
@ -345,25 +460,58 @@ class FalAIVideoConfig(BaseVideoConfig):
custom_llm_provider: str | None = None,
) -> VideoObject:
response_data: Final[Mapping[str, object]] = _response_data(raw_response)
raw_status: Final[str] = _response_string(response_data, "status", "IN_QUEUE")
status: Final[str] = _STATUS_MAP.get(raw_status, "queued")
error_value: Final[object] = response_data.get("error")
error: Final[str | None] = error_value if isinstance(error_value, str) else None
provider: Final[str] = custom_llm_provider or _FAL_AI_PROVIDER
model_path: Final[str | None] = _model_path_from_request_url(raw_response)
request_id: Final[str] = _response_string(response_data, "request_id") or (
_request_id_from_request_url(raw_response) or ""
result_error: Final[str | None] = self._fetch_result_error(raw_response, response_data)
return _status_video_object(
response_data=response_data,
raw_response=raw_response,
custom_llm_provider=custom_llm_provider,
result_error=result_error,
)
return VideoObject(
id=encode_video_id_with_provider(request_id, provider, model_path),
object="video",
status="failed" if error else status,
created_at=0,
model=model_path,
error=(
{"code": "fal_error", "message": error} if error else None # mutable-ok: VideoObject requires a dict
),
def _fetch_result_error(
self,
raw_response: httpx.Response,
response_data: Mapping[str, object],
) -> str | None:
result_request: Final[tuple[str, Mapping[str, str]] | None] = _result_request(raw_response, response_data)
if result_request is None:
return None
result_url, result_headers = result_request
result_response: Final[httpx.Response] = self._sync_client_factory().get(
url=result_url,
headers=result_headers,
)
return _terminal_result_error(result_response)
async def async_transform_video_status_retrieve_response(
self,
raw_response: httpx.Response,
logging_obj: object,
custom_llm_provider: str | None = None,
) -> VideoObject:
response_data: Final[Mapping[str, object]] = _response_data(raw_response)
result_error: Final[str | None] = await self._fetch_result_error_async(raw_response, response_data)
return _status_video_object(
response_data=response_data,
raw_response=raw_response,
custom_llm_provider=custom_llm_provider,
result_error=result_error,
)
async def _fetch_result_error_async(
self,
raw_response: httpx.Response,
response_data: Mapping[str, object],
) -> str | None:
result_request: Final[tuple[str, Mapping[str, str]] | None] = _result_request(raw_response, response_data)
if result_request is None:
return None
result_url, result_headers = result_request
result_response: Final[httpx.Response] = await self._async_client_factory().get(
url=result_url,
headers=result_headers,
)
return _terminal_result_error(result_response)
@staticmethod
def _decode_video_id(video_id: str) -> tuple[str, str]:
@ -401,17 +549,23 @@ class FalAIVideoConfig(BaseVideoConfig):
video_url: Final[object] = video_data.get("url")
if isinstance(video_url, str) and video_url:
return video_url
error_message: Final[str | None] = next(
(value for key in ("error", "detail") if isinstance(value := response_data.get(key), str)),
None,
)
error_message: Final[str | None] = _error_text(response_data)
if error_message:
raise ValueError(f"fal.ai video result did not include a video URL: {error_message}")
raise ValueError("fal.ai video result did not include a video URL")
def transform_video_content_response(self, raw_response: httpx.Response, logging_obj: object) -> bytes:
error: Final[str | None] = _result_error(raw_response)
if error is not None:
raise FalAIVideoError(
status_code=raw_response.status_code,
message=error,
headers=dict(raw_response.headers), # mutable-ok: exception headers require a mutable dictionary
request=raw_response.request,
response=raw_response,
)
video_url: Final[str] = self._extract_video_url(_response_data(raw_response))
httpx_client: Final[HTTPHandler] = _get_httpx_client()
httpx_client: Final[HTTPHandler] = self._sync_client_factory()
video_response: Final[httpx.Response] = httpx_client.get( # pyright: ignore[reportUnknownMemberType] # HTTP handler stubs are untyped
video_url
)
@ -419,8 +573,17 @@ class FalAIVideoConfig(BaseVideoConfig):
return video_response.content
async def async_transform_video_content_response(self, raw_response: httpx.Response, logging_obj: object) -> bytes:
error: Final[str | None] = _result_error(raw_response)
if error is not None:
raise FalAIVideoError(
status_code=raw_response.status_code,
message=error,
headers=dict(raw_response.headers), # mutable-ok: exception headers require a mutable dictionary
request=raw_response.request,
response=raw_response,
)
video_url: Final[str] = self._extract_video_url(_response_data(raw_response))
async_httpx_client: Final[AsyncHTTPHandler] = get_async_httpx_client(llm_provider=LlmProviders.FAL_AI)
async_httpx_client: Final[AsyncHTTPHandler] = self._async_client_factory()
video_response: Final[httpx.Response] = await async_httpx_client.get( # pyright: ignore[reportUnknownMemberType] # HTTP handler stubs are untyped
video_url
)

View file

@ -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

View file

@ -730,6 +730,9 @@ from litellm.proxy.spend_tracking.spend_management_endpoints import (
)
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,
)
@ -19267,6 +19270,7 @@ app.include_router(debugging_endpoints_router)
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)

View file

@ -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

View file

@ -166,6 +166,9 @@
"tests/integration/providers/test_fal_ai_video_wire.py::test_fal_video_create_status_and_content_follow_queue_wire_contract": [
"other.provider_wire.fal_ai.video_queue_create_status_and_content_download"
],
"tests/integration/providers/test_fal_ai_video_wire.py::test_fal_video_failed_result_reports_failed_status_and_fal_error": [
"other.provider_wire.fal_ai.video_failed_result_surfaces_fal_error"
],
"tests/integration/providers/test_fal_ai_image_wire.py::test_fal_gpt_image_25_generation_sends_quality_and_size_and_charges_keyed_row": [
"other.provider_wire.fal_ai.gpt_image_generation_quality_size_wire_and_keyed_pricing"
],

View file

@ -66,6 +66,7 @@ def test_fal_video_create_status_and_content_follow_queue_wire_contract(gateway:
("POST", f"/{_MODEL}"),
("GET", f"/bytedance/seedance-2.5/requests/{request_id}/status"),
("GET", f"/bytedance/seedance-2.5/requests/{request_id}"),
("GET", f"/bytedance/seedance-2.5/requests/{request_id}"),
("GET", f"/files/{request_id}.mp4"),
]
@ -119,5 +120,57 @@ def test_fal_h3_video_create_uses_canonical_body_and_status_path(gateway: Gatewa
("POST", f"/{_H3_MODEL}"),
("GET", f"/minimax/h3/requests/{request_id}/status"),
("GET", f"/minimax/h3/requests/{request_id}"),
("GET", f"/minimax/h3/requests/{request_id}"),
("GET", f"/files/{request_id}.mp4"),
]
@pytest.mark.covers("other.provider_wire.fal_ai.video_failed_result_surfaces_fal_error")
def test_fal_video_failed_result_reports_failed_status_and_fal_error(gateway: Gateway) -> None:
request_id: Final = "fal-failed-req-" + uuid.uuid4().hex
error_body: Final = {
"detail": [
{
"loc": ["body", "input.reference_image_urls"],
"msg": "Failed to download the file. Please check if the URL is accessible and try again.",
"type": "file_download_error",
}
]
}
def respond(request: Request) -> Reply:
assert request.headers["authorization"] == "Key synthetic-fal-key"
if request.method == "POST":
assert request.target == f"/{_MODEL}"
return Reply(
body=json.dumps({"status": "IN_QUEUE", "request_id": request_id, "queue_position": 0}).encode()
)
assert request.method == "GET"
if request.target == f"/bytedance/seedance-2.5/requests/{request_id}/status":
return Reply(body=json.dumps({"status": "COMPLETED", "request_id": request_id}).encode())
assert request.target == f"/bytedance/seedance-2.5/requests/{request_id}"
return Reply(status=422, body=json.dumps(error_body).encode())
with wire_server(respond) as wire, gateway.scenario() as scenario:
model: Final = scenario.model(
model=f"fal_ai/{_MODEL}",
api_base=wire.url,
api_key="synthetic-fal-key",
)
created: Final = gateway.post(
"/v1/videos",
{
"model": model,
"prompt": "a cat playing volleyball on a beach",
"seconds": "4",
"size": "1280x720",
},
)
assert created["status"] == "queued"
video_id: Final = created["id"]
status: Final = gateway.get(f"/v1/videos/{video_id}")
assert status["status"] == "failed"
assert "input.reference_image_urls: Failed to download the file" in status["error"]["message"]
content: Final = gateway.request("GET", f"/v1/videos/{video_id}/content")
assert content.status_code == 422, content.text
assert "Failed to download the file" in content.text

View file

@ -1,4 +1,5 @@
from unittest.mock import Mock
from typing import Final
from unittest.mock import AsyncMock, Mock
import httpx
import pytest
@ -218,8 +219,18 @@ class TestFalAIVideoTransformation:
def test_status_response_mapping(self, response_data, expected_status):
status_url = "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status"
response = httpx.Response(200, json=response_data, request=httpx.Request("GET", status_url))
config = self.config
if expected_status == "completed":
result_response: Final = httpx.Response(
200,
json={"video": {"url": "https://cdn.example.com/video.mp4"}},
request=httpx.Request("GET", status_url.removesuffix("/status")),
)
client: Final = Mock()
client.get.return_value = result_response
config = FalAIVideoConfig(sync_client_factory=lambda: client)
video = self.config.transform_video_status_retrieve_response(
video = config.transform_video_status_retrieve_response(
raw_response=response,
logging_obj=self.logging_obj,
custom_llm_provider="fal_ai",
@ -245,16 +256,22 @@ class TestFalAIVideoTransformation:
"status": "COMPLETED",
"error": "generation failed",
}
status_url = "https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status"
response = httpx.Response(
200,
json=response_data,
request=httpx.Request(
"GET",
"https://queue.fal.run/bytedance/seedance-2.5/requests/abc/status",
),
request=httpx.Request("GET", status_url),
)
result_response: Final = httpx.Response(
200,
json={"video": {"url": "https://cdn.example.com/video.mp4"}},
request=httpx.Request("GET", status_url.removesuffix("/status")),
)
client: Final = Mock()
client.get.return_value = result_response
config = FalAIVideoConfig(sync_client_factory=lambda: client)
video = self.config.transform_video_status_retrieve_response(
video = config.transform_video_status_retrieve_response(
raw_response=response,
logging_obj=self.logging_obj,
custom_llm_provider="fal_ai",
@ -263,8 +280,125 @@ class TestFalAIVideoTransformation:
assert video.status == "failed"
assert video.error == {"code": "fal_error", "message": "generation failed"}
def test_status_response_uses_namespaced_request_url(self):
def test_status_completed_result_error_surfaces_fal_message(self):
status_url = "https://queue.fal.run/minimax/h3/requests/abc/status"
auth_headers: Final = {"Authorization": "Key synthetic-fal-key", "Content-Type": "application/json"}
response: Final = httpx.Response(
200,
json={"request_id": "abc", "status": "COMPLETED"},
request=httpx.Request("GET", status_url, headers=auth_headers),
)
result_url: Final = status_url.removesuffix("/status")
result_response: Final = httpx.Response(
422,
json={
"detail": [
{
"loc": ["body", "input.reference_image_urls"],
"msg": "Failed to download the file. Please check if the URL is accessible and try again.",
}
]
},
request=httpx.Request("GET", result_url, headers=auth_headers),
)
client: Final = Mock()
client.get.return_value = result_response
config = FalAIVideoConfig(sync_client_factory=lambda: client)
video = config.transform_video_status_retrieve_response(
raw_response=response,
logging_obj=self.logging_obj,
custom_llm_provider="fal_ai",
)
assert video.status == "failed"
assert "input.reference_image_urls: Failed to download the file" in video.error["message"]
client.get.assert_called_once_with(url=result_url, headers=auth_headers)
@pytest.mark.parametrize("status_code", [429, 503])
def test_status_completed_transient_result_error_keeps_completed(self, status_code):
status_url = "https://queue.fal.run/minimax/h3/requests/abc/status"
response: Final = httpx.Response(
200,
json={"request_id": "abc", "status": "COMPLETED"},
request=httpx.Request("GET", status_url),
)
result_response: Final = httpx.Response(
status_code,
json={"detail": "temporary fal failure"},
request=httpx.Request("GET", status_url.removesuffix("/status")),
)
client: Final = Mock()
client.get.return_value = result_response
config = FalAIVideoConfig(sync_client_factory=lambda: client)
video = config.transform_video_status_retrieve_response(
raw_response=response,
logging_obj=self.logging_obj,
custom_llm_provider="fal_ai",
)
assert video.status == "completed"
assert video.error is None
@pytest.mark.asyncio
async def test_async_status_completed_result_error_surfaces_fal_message(self):
status_url = "https://queue.fal.run/minimax/h3/requests/abc/status"
auth_headers: Final = {"Authorization": "Key synthetic-fal-key", "Content-Type": "application/json"}
response: Final = httpx.Response(
200,
json={"request_id": "abc", "status": "COMPLETED"},
request=httpx.Request("GET", status_url, headers=auth_headers),
)
result_url: Final = status_url.removesuffix("/status")
result_response: Final = httpx.Response(
422,
json={
"detail": [
{
"loc": ["body", "input.reference_image_urls"],
"msg": "Failed to download the file. Please check if the URL is accessible and try again.",
}
]
},
request=httpx.Request("GET", result_url, headers=auth_headers),
)
client: Final = Mock()
client.get = AsyncMock(return_value=result_response)
config = FalAIVideoConfig(async_client_factory=lambda: client)
video = await config.async_transform_video_status_retrieve_response(
raw_response=response,
logging_obj=self.logging_obj,
custom_llm_provider="fal_ai",
)
assert video.status == "failed"
assert "input.reference_image_urls: Failed to download the file" in video.error["message"]
client.get.assert_awaited_once_with(url=result_url, headers=auth_headers)
def test_status_in_progress_does_not_fetch_result(self):
status_url = "https://queue.fal.run/minimax/h3/requests/abc/status"
response = httpx.Response(
200,
json={"request_id": "abc", "status": "IN_PROGRESS"},
request=httpx.Request("GET", status_url),
)
client: Final = Mock()
config = FalAIVideoConfig(sync_client_factory=lambda: client)
video = config.transform_video_status_retrieve_response(
raw_response=response,
logging_obj=self.logging_obj,
custom_llm_provider="fal_ai",
)
assert video.status == "in_progress"
client.get.assert_not_called()
def test_status_response_uses_namespaced_request_url(self):
response: Final = httpx.Response(
200,
json={"status": "IN_PROGRESS"},
request=httpx.Request(
@ -284,7 +418,7 @@ class TestFalAIVideoTransformation:
assert decoded["video_id"] == "xyz"
assert video.model == "workflows/owner/app"
def test_content_response_downloads_video_url(self, monkeypatch):
def test_content_response_downloads_video_url(self):
content_response = httpx.Response(
200,
content=b"video-bytes",
@ -296,11 +430,11 @@ class TestFalAIVideoTransformation:
assert url == "https://cdn.example.com/video.mp4"
return content_response
monkeypatch.setattr(fal_video_module, "_get_httpx_client", lambda: FakeHTTPClient())
config = FalAIVideoConfig(sync_client_factory=FakeHTTPClient)
response = Mock(spec=httpx.Response)
response.json.return_value = {"video": {"url": "https://cdn.example.com/video.mp4"}}
assert self.config.transform_video_content_response(response, self.logging_obj) == b"video-bytes"
assert config.transform_video_content_response(response, self.logging_obj) == b"video-bytes"
def test_content_response_rejects_missing_video(self):
response = Mock(spec=httpx.Response)
@ -309,6 +443,87 @@ class TestFalAIVideoTransformation:
with pytest.raises(ValueError, match="generation failed"):
self.config.transform_video_content_response(response, self.logging_obj)
def test_content_response_surfaces_list_detail_error(self):
response: Final = httpx.Response(
422,
json={
"detail": [
{
"loc": ["body", "input.reference_image_urls"],
"msg": "Failed to download the file. Please check if the URL is accessible and try again.",
}
]
},
request=httpx.Request("GET", "https://queue.fal.run/minimax/h3/requests/abc"),
)
with pytest.raises(FalAIVideoError) as error:
self.config.transform_video_content_response(response, self.logging_obj)
assert error.value.status_code == 422
assert "input.reference_image_urls: Failed to download the file" in error.value.message
assert "Failed to download the file" in error.value.response.text
def test_content_response_surfaces_string_detail_error(self):
response: Final = httpx.Response(
400,
json={"detail": "Request is still in progress"},
request=httpx.Request("GET", "https://queue.fal.run/minimax/h3/requests/abc"),
)
with pytest.raises(FalAIVideoError) as error:
self.config.transform_video_content_response(response, self.logging_obj)
assert error.value.status_code == 400
assert error.value.message == "Request is still in progress"
assert "Request is still in progress" in error.value.response.text
@pytest.mark.asyncio
async def test_async_content_response_surfaces_list_detail_error(self):
response: Final = httpx.Response(
422,
json={
"detail": [
{
"loc": ["body", "input.reference_image_urls"],
"msg": "Failed to download the file. Please check if the URL is accessible and try again.",
}
]
},
request=httpx.Request("GET", "https://queue.fal.run/minimax/h3/requests/abc"),
)
with pytest.raises(FalAIVideoError) as error:
await self.config.async_transform_video_content_response(response, self.logging_obj)
assert error.value.status_code == 422
assert "input.reference_image_urls: Failed to download the file" in error.value.message
assert "Failed to download the file" in error.value.response.text
@pytest.mark.asyncio
async def test_async_content_response_surfaces_string_detail_error(self):
response = httpx.Response(
400,
json={"detail": "Request is still in progress"},
request=httpx.Request("GET", "https://queue.fal.run/minimax/h3/requests/abc"),
)
with pytest.raises(FalAIVideoError) as error:
await self.config.async_transform_video_content_response(response, self.logging_obj)
assert error.value.status_code == 400
assert error.value.message == "Request is still in progress"
assert "Request is still in progress" in error.value.response.text
def test_extract_video_url_surfaces_list_detail_error(self):
response: Final = Mock(spec=httpx.Response)
response.json.return_value = {
"detail": [{"loc": ["body", "input.reference_image_urls"], "msg": "Failed to download the file"}]
}
with pytest.raises(ValueError, match=r"input\.reference_image_urls: Failed to download the file"):
self.config.transform_video_content_response(response, self.logging_obj)
def test_provider_config_and_error_class(self):
provider_config = ProviderConfigManager.get_provider_video_config(
model=MODEL,

View file

@ -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(

View file

@ -0,0 +1,260 @@
import asyncio
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,
_default_fetch_lock,
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": 4,
"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
app.dependency_overrides[_default_fetch_lock] = lambda: asyncio.Lock()
@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)
app.dependency_overrides.pop(_default_fetch_lock, 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"] == 4
def test_unprefixed_bullets_count_as_other_updates(self):
counts = count_release_bullets("* Litellm dev 09 08 2026 by @f in https://x/pull/7\n")
assert (counts["new_features"], counts["bug_fixes"], counts["other_updates"]) == (0, 0, 1)
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"
"\n**Full Changelog**: https://github.com/BerriAI/litellm/compare/v1...v2\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(), fetch_lock=asyncio.Lock())
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()
fetch_lock = asyncio.Lock()
first = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
second = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
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, fetch_lock=asyncio.Lock()
)
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()
fetch_lock = asyncio.Lock()
first = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
second = await get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock)
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(), fetch_lock=asyncio.Lock()
)
assert isinstance(result, LatestReleaseUnavailable)
@pytest.mark.asyncio
async def test_concurrent_misses_share_one_fetch(self):
event = asyncio.Event()
class _BlockingClient(_RecordingClient):
async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response:
self.calls.append((url, timeout))
await event.wait()
return _github_response()
client = _BlockingClient([])
cache = _fresh_cache()
fetch_lock = asyncio.Lock()
tasks = [
asyncio.create_task(get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock))
for _ in range(5)
]
await asyncio.sleep(0)
await asyncio.sleep(0)
event.set()
results = await asyncio.gather(*tasks)
expected: Final = LatestReleaseInfo.model_validate(EXPECTED_INFO)
assert results == [expected] * 5
assert len(client.calls) == 1
@pytest.mark.asyncio
async def test_failure_under_lock_is_also_coalesced(self):
event = asyncio.Event()
class _FailingBlockingClient(_RecordingClient):
async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response:
self.calls.append((url, timeout))
await event.wait()
raise httpx.ConnectError("boom")
client = _FailingBlockingClient([])
cache = _fresh_cache()
fetch_lock = asyncio.Lock()
tasks = [
asyncio.create_task(get_latest_release_info(client=client, cache=cache, fetch_lock=fetch_lock))
for _ in range(5)
]
await asyncio.sleep(0)
await asyncio.sleep(0)
event.set()
results = await asyncio.gather(*tasks)
assert all(isinstance(result, LatestReleaseUnavailable) for result in results)
assert len(client.calls) == 1
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)

View file

@ -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,
},
);

View file

@ -41,6 +41,10 @@ vi.mock("@/components/UserBanner", () => ({
UserBanner: () => null,
}));
vi.mock("@/components/UpgradeBanner", () => ({
UpgradeBanner: () => null,
}));
vi.mock("@/contexts/ThemeContext", () => ({
ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));

View file

@ -13,6 +13,7 @@ import { NoRedisWarningBanner } from "@/components/NoRedisWarningBanner";
import { EnvCredentialLoginWarningBanner } from "@/components/EnvCredentialLoginWarningBanner";
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";
@ -117,6 +118,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<EnvCredentialLoginWarningBanner accessToken={accessToken} />
<LicenseExpiryBanner accessToken={accessToken} />
<UserBanner accessToken={accessToken} />
<UpgradeBanner accessToken={accessToken} />
<main className="flex min-h-0 flex-1 overflow-hidden">
<AgentControlPlaneView />
</main>
@ -137,6 +139,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) {
<EnvCredentialLoginWarningBanner 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>

View file

@ -0,0 +1,131 @@
import React from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { describeRelease, UpgradeBanner, UpgradeBannerView } from "./UpgradeBanner";
import type { LatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({
useHealthReadinessDetails: vi.fn(),
}));
vi.mock("@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo", () => ({
useLatestReleaseInfo: vi.fn(),
}));
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import { useLatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
const RELEASE: LatestReleaseInfo = {
version: "1.103.0",
new_features: 12,
bug_fixes: 30,
other_updates: 8,
release_url: "https://github.com/BerriAI/litellm/releases/tag/v1.103.0",
};
describe("describeRelease", () => {
it("lists features, fixes, and other updates in the agreed order", () => {
expect(describeRelease(RELEASE)).toBe("12 new features, 30 fixes, and 8 other updates");
});
it("singularises counts of one", () => {
const singularCounts = { ...RELEASE, new_features: 1, bug_fixes: 1, other_updates: 1 };
expect(describeRelease(singularCounts)).toBe("1 new feature, 1 fix, and 1 other update");
});
});
describe("UpgradeBannerView", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it("renders nothing while either version is unknown", () => {
const { container } = render(<UpgradeBannerView currentVersion={undefined} latestRelease={RELEASE} />);
expect(container).toBeEmptyDOMElement();
const { container: noRelease } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={null} />);
expect(noRelease).toBeEmptyDOMElement();
});
it("renders nothing when the running version is up to date or ahead", () => {
const { container } = render(<UpgradeBannerView currentVersion="1.103.0" latestRelease={RELEASE} />);
expect(container).toBeEmptyDOMElement();
const { container: ahead } = render(<UpgradeBannerView currentVersion="1.104.0-dev.1" latestRelease={RELEASE} />);
expect(ahead).toBeEmptyDOMElement();
});
it("shows the latest version, the stat line, and the current version when behind", () => {
render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
const alert = screen.getByRole("status");
expect(alert).toHaveTextContent("The latest version is v1.103.0: 12 new features, 30 fixes, and 8 other updates");
expect(alert).toHaveTextContent("Your current version is v1.102.0");
expect(screen.getByRole("link", { name: "v1.103.0" })).toHaveAttribute("href", RELEASE.release_url);
});
it("dismissing hides the banner and keeps it hidden on remount for the same release", () => {
const { unmount } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
unmount();
const { container } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
expect(container).toBeEmptyDOMElement();
});
it("reappears once a newer release ships after a dismissal", () => {
const { unmount } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
unmount();
render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={{ ...RELEASE, version: "1.104.0" }} />);
expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
});
it("shows a newer release after the current one was dismissed without remounting", () => {
const { rerender } = render(<UpgradeBannerView currentVersion="1.102.0" latestRelease={RELEASE} />);
fireEvent.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("status")).not.toBeInTheDocument();
rerender(<UpgradeBannerView currentVersion="1.102.0" latestRelease={{ ...RELEASE, version: "1.104.0" }} />);
expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
});
});
describe("UpgradeBanner", () => {
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
localStorage.clear();
});
it("feeds both hooks the access token and renders from their data", () => {
const healthReadinessResult = {
data: { litellm_version: "1.102.0" },
} as Partial<ReturnType<typeof useHealthReadinessDetails>> as ReturnType<typeof useHealthReadinessDetails>;
const latestReleaseResult = { data: RELEASE } as Partial<ReturnType<typeof useLatestReleaseInfo>> as ReturnType<
typeof useLatestReleaseInfo
>;
vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
render(<UpgradeBanner accessToken="token" />);
expect(useHealthReadinessDetails).toHaveBeenCalledWith("token");
expect(useLatestReleaseInfo).toHaveBeenCalledWith("token");
expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.103.0");
});
it("renders nothing when the release endpoint returns null", () => {
const healthReadinessResult = {
data: { litellm_version: "1.102.0" },
} as Partial<ReturnType<typeof useHealthReadinessDetails>> as ReturnType<typeof useHealthReadinessDetails>;
const latestReleaseResult = { data: null } as Partial<ReturnType<typeof useLatestReleaseInfo>> as ReturnType<
typeof useLatestReleaseInfo
>;
vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
const { container } = render(<UpgradeBanner accessToken="token" />);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -0,0 +1,77 @@
"use client";
import React, { useState } from "react";
import { ArrowUpCircle, X } from "lucide-react";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import {
type LatestReleaseInfo,
useLatestReleaseInfo,
} from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
import { isNewerVersion } from "@/utils/versionUtils";
const DISMISS_KEY_PREFIX = "litellm:upgradeBannerDismissed:";
interface UpgradeBannerProps {
accessToken: string | null;
}
interface UpgradeBannerViewProps {
currentVersion: string | null | undefined;
latestRelease: LatestReleaseInfo | null | undefined;
}
const plural = (count: number, singular: string, pluralForm: string): string =>
`${count} ${count === 1 ? singular : pluralForm}`;
export const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string =>
[
plural(new_features, "new feature", "new features"),
plural(bug_fixes, "fix", "fixes"),
`and ${plural(other_updates, "other update", "other updates")}`,
].join(", ");
export const UpgradeBannerView: React.FC<UpgradeBannerViewProps> = ({ currentVersion, latestRelease }) => {
const [dismissedVersion, setDismissedVersion] = useState<string | null>(null);
if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) {
return null;
}
const dismissKey = `${DISMISS_KEY_PREFIX}${latestRelease.version}`;
if (dismissedVersion === latestRelease.version || getLocalStorageItem(dismissKey) === "true") {
return null;
}
const handleClose = () => {
setLocalStorageItem(dismissKey, "true");
setDismissedVersion(latestRelease.version);
};
return (
<Alert role="status" variant="info" className="rounded-none border-x-0 border-t-0">
<ArrowUpCircle className="size-4" aria-hidden />
<AlertTitle>
The latest version is{" "}
<a href={latestRelease.release_url} target="_blank" rel="noopener noreferrer" className="underline">
v{latestRelease.version}
</a>
: {describeRelease(latestRelease)}
</AlertTitle>
<AlertDescription>Your current version is v{currentVersion}</AlertDescription>
<AlertAction>
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={handleClose}>
<X className="size-4" />
</Button>
</AlertAction>
</Alert>
);
};
export const UpgradeBanner: React.FC<UpgradeBannerProps> = ({ accessToken }) => {
const { data: healthData } = useHealthReadinessDetails(accessToken);
const { data: latestRelease } = useLatestReleaseInfo(accessToken);
return <UpgradeBannerView currentVersion={healthData?.litellm_version} latestRelease={latestRelease} />;
};

View file

@ -5319,6 +5319,27 @@ export interface paths {
patch?: never;
trace?: never;
};
"/get/latest_release_info": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Latest Release Info
* @description 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.
*/
get: operations["latest_release_info_get_latest_release_info_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/get/mcp_semantic_filter_settings": {
parameters: {
query?: never;
@ -29831,6 +29852,19 @@ export interface components {
} & {
[key: string]: unknown;
};
/** LatestReleaseInfo */
LatestReleaseInfo: {
/** Bug Fixes */
bug_fixes: number;
/** New Features */
new_features: number;
/** Other Updates */
other_updates: number;
/** Release Url */
release_url: string;
/** Version */
version: string;
};
/** ListAccessGroupsResponse */
ListAccessGroupsResponse: {
/** Access Groups */
@ -49914,6 +49948,26 @@ export interface operations {
};
};
};
latest_release_info_get_latest_release_info_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["LatestReleaseInfo"] | null;
};
};
};
};
get_mcp_semantic_filter_settings_get_mcp_semantic_filter_settings_get: {
parameters: {
query?: never;

View file

@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { isNewerVersion, parseReleaseVersion } from "./versionUtils";
describe("parseReleaseVersion", () => {
it("reads the numeric components with or without a leading v", () => {
expect(parseReleaseVersion("1.102.0")).toEqual([1, 102, 0]);
expect(parseReleaseVersion("v1.102.0")).toEqual([1, 102, 0]);
});
it("ignores a prerelease suffix", () => {
expect(parseReleaseVersion("1.102.0-dev.3")).toEqual([1, 102, 0]);
expect(parseReleaseVersion("1.102.0.rc1")).toEqual([1, 102, 0]);
});
it("returns null for strings that are not a release version", () => {
expect(parseReleaseVersion("")).toBeNull();
expect(parseReleaseVersion("latest")).toBeNull();
expect(parseReleaseVersion("1.102")).toBeNull();
});
});
describe("isNewerVersion", () => {
it("is false when the versions are equal", () => {
expect(isNewerVersion("1.102.0", "1.102.0")).toBe(false);
expect(isNewerVersion("1.102.0", "v1.102.0")).toBe(false);
});
it("is true when the latest version is ahead on any component", () => {
expect(isNewerVersion("1.102.0", "1.102.1")).toBe(true);
expect(isNewerVersion("1.102.5", "1.103.0")).toBe(true);
expect(isNewerVersion("1.999.9", "2.0.0")).toBe(true);
});
it("is false when the running version is already ahead", () => {
expect(isNewerVersion("1.103.0", "1.102.9")).toBe(false);
expect(isNewerVersion("2.0.0", "1.999.9")).toBe(false);
});
it("compares components numerically rather than as strings", () => {
expect(isNewerVersion("1.9.0", "1.10.0")).toBe(true);
expect(isNewerVersion("1.10.0", "1.9.0")).toBe(false);
});
it("treats a prerelease of the latest version as not behind", () => {
expect(isNewerVersion("1.102.0-dev.1", "1.102.0")).toBe(false);
expect(isNewerVersion("1.101.0-dev.1", "1.102.0")).toBe(true);
});
it("is false when either version cannot be parsed", () => {
expect(isNewerVersion("unknown", "1.102.0")).toBe(false);
expect(isNewerVersion("1.102.0", "")).toBe(false);
});
});

View file

@ -0,0 +1,23 @@
const RELEASE_VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)/;
export const parseReleaseVersion = (version: string): readonly [number, number, number] | null => {
const match = RELEASE_VERSION_PATTERN.exec(version.trim());
if (!match) {
return null;
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
};
export const isNewerVersion = (current: string, latest: string): boolean => {
const currentParts = parseReleaseVersion(current);
const latestParts = parseReleaseVersion(latest);
if (!currentParts || !latestParts) {
return false;
}
for (let i = 0; i < 3; i += 1) {
if (latestParts[i] !== currentParts[i]) {
return latestParts[i] > currentParts[i];
}
}
return false;
};