mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
Merge remote-tracking branch 'origin/litellm_cost_shard_pricing_dimensions' into litellm_cost_shard_provider_wires
This commit is contained in:
commit
5034b96fb6
19 changed files with 1289 additions and 36 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
153
litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py
Normal file
153
litellm/proxy/ui_crud_endpoints/latest_release_endpoints.py
Normal 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
|
||||
|
|
@ -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"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
|
|
@ -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}</>,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
131
ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx
Normal file
131
ui/litellm-dashboard/src/components/UpgradeBanner.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
77
ui/litellm-dashboard/src/components/UpgradeBanner.tsx
Normal file
77
ui/litellm-dashboard/src/components/UpgradeBanner.tsx
Normal 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} />;
|
||||
};
|
||||
54
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
54
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
53
ui/litellm-dashboard/src/utils/versionUtils.test.ts
Normal file
53
ui/litellm-dashboard/src/utils/versionUtils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
ui/litellm-dashboard/src/utils/versionUtils.ts
Normal file
23
ui/litellm-dashboard/src/utils/versionUtils.ts
Normal 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;
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue