diff --git a/litellm/__init__.py b/litellm/__init__.py index 3049f07c44c..342a24208f0 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -439,6 +439,10 @@ autorouter_presets_url: str = os.getenv( "LITELLM_AUTOROUTER_PRESETS_URL", "https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/proxy/public_endpoints/autorouter_presets.json", ) +whats_new_url: str = os.getenv( + "LITELLM_WHATS_NEW_URL", + "https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/proxy/public_endpoints/whats_new.json", +) suppress_debug_info: bool = False dynamodb_table_name: Optional[str] = None s3_callback_params: Optional[Dict] = None diff --git a/litellm/litellm_core_utils/get_blog_posts.py b/litellm/litellm_core_utils/get_blog_posts.py index 276d2a968a1..0dd09064dcc 100644 --- a/litellm/litellm_core_utils/get_blog_posts.py +++ b/litellm/litellm_core_utils/get_blog_posts.py @@ -22,6 +22,7 @@ from litellm import verbose_logger from litellm.types.llms.base import LiteLLMBaseModel BLOG_POSTS_TTL_SECONDS: Final[int] = 3600 # 1 hour +BLOG_POSTS_MAX: Final[int] = 6 class BlogPost(LiteLLMBaseModel): @@ -40,7 +41,7 @@ class GetBlogPosts: Fetches, validates, and caches LiteLLM blog posts. - Fetches RSS feed from docs site with a 5-second timeout - - Parses the XML and extracts the latest blog post + - Parses the XML and extracts the latest BLOG_POSTS_MAX blog posts - Caches the result in-process for BLOG_POSTS_TTL_SECONDS (1 hour) - Falls back to the bundled local backup on any failure """ @@ -66,7 +67,7 @@ class GetBlogPosts: return response.text @staticmethod - def parse_rss_to_posts(xml_text: str, max_posts: int = 1) -> list[dict[str, str]]: + def parse_rss_to_posts(xml_text: str, max_posts: int = BLOG_POSTS_MAX) -> list[dict[str, str]]: """ Parse RSS XML and return a list of blog post dicts. diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 1cccc6b2cfb..75e8a5af6a5 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -6,8 +6,9 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence from importlib.resources import files from typing import TYPE_CHECKING, Final, Protocol +import httpx from fastapi import APIRouter, HTTPException, Request -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError from typing_extensions import ReadOnly, TypedDict import litellm @@ -37,6 +38,7 @@ from litellm.types.proxy.public_endpoints.public_endpoints import ( ProviderCreateInfo, PublicModelHubInfo, SupportedEndpointsResponse, + WhatsNewResponse, ) from litellm.types.utils import LlmProviders @@ -490,7 +492,7 @@ async def get_litellm_blog_posts(): verbose_logger.warning("LiteLLM: get_litellm_blog_posts endpoint fallback triggered: %s", str(e)) posts_data = GetBlogPosts.load_local_blog_posts() - posts: Final = [BlogPost(**p) for p in posts_data[:5]] + posts: Final = [BlogPost(**p) for p in posts_data] return BlogPostsResponse(posts=posts) @@ -505,14 +507,18 @@ def _load_bundled_autorouter_presets() -> Mapping[str, AutoRouterPresetRecord]: ) -async def _fetch_remote_autorouter_presets(url: str) -> Mapping[str, AutoRouterPresetRecord]: +async def _fetch_remote_bytes(url: str) -> bytes: from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.types.llms.custom_http import httpxSpecialProvider client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.UI) response: Final = await client.get(url, timeout=5.0) response.raise_for_status() - presets: Final = _AUTOROUTER_PRESETS_ADAPTER.validate_python(response.json()) + return response.content + + +async def _fetch_remote_autorouter_presets(url: str) -> Mapping[str, AutoRouterPresetRecord]: + presets: Final = _AUTOROUTER_PRESETS_ADAPTER.validate_json(await _fetch_remote_bytes(url)) if not presets: raise ValueError("remote auto-router preset catalog is empty") return presets @@ -575,6 +581,70 @@ async def get_public_autorouter_presets() -> Mapping[str, AutoRouterPresetRecord return await get_autorouter_presets(url=litellm.autorouter_presets_url) +def _load_bundled_whats_new() -> WhatsNewResponse: + return WhatsNewResponse.model_validate_json( + files("litellm.proxy.public_endpoints").joinpath("whats_new.json").read_text(encoding="utf-8") + ) + + +async def _fetch_remote_whats_new(url: str) -> WhatsNewResponse: + return WhatsNewResponse.model_validate_json(await _fetch_remote_bytes(url)) + + +async def _resolve_whats_new(url: str, fetch: Callable[[str], Awaitable[WhatsNewResponse]]) -> WhatsNewResponse: + if os.getenv("LITELLM_LOCAL_WHATS_NEW", "").lower() == "true": + return _load_bundled_whats_new() + try: + return await fetch(url) + except (httpx.HTTPError, ValidationError, OSError) as e: + verbose_logger.warning( + "LiteLLM: failed to fetch What's new launches from %s: %s. Serving the bundled list for the life of this process.", + url, + str(e), + ) + return _load_bundled_whats_new() + + +class _WhatsNewCache: + launches: WhatsNewResponse | None = None + lock: asyncio.Lock | None = None + + +async def get_whats_new( + url: str, + fetch: Callable[[str], Awaitable[WhatsNewResponse]] = _fetch_remote_whats_new, +) -> WhatsNewResponse: + cached: Final = _WhatsNewCache.launches + if cached is not None: + return cached + if _WhatsNewCache.lock is None: + _WhatsNewCache.lock = asyncio.Lock() + async with _WhatsNewCache.lock: + held: Final = _WhatsNewCache.launches + if held is not None: + return held + resolved: Final = await _resolve_whats_new(url=url, fetch=fetch) + _WhatsNewCache.launches = resolved + return resolved + + +@router.get( + "/public/whats_new", + tags=["public"], + response_model=WhatsNewResponse, +) +async def get_public_whats_new() -> WhatsNewResponse: + """ + Return the launches the dashboard Home page shows under What's new. + + Resolved once per process: fetched from ``litellm.whats_new_url`` (override with ``LITELLM_WHATS_NEW_URL``) + on the first request, falling back to the list bundled with the package on any failure, so an airgapped + proxy serves the bundled list without retrying. Set ``LITELLM_LOCAL_WHATS_NEW=True`` to skip the fetch. + A restart picks up a newly published list. + """ + return await get_whats_new(url=litellm.whats_new_url) + + @router.get( "/public/endpoints", tags=["public"], diff --git a/litellm/proxy/public_endpoints/whats_new.json b/litellm/proxy/public_endpoints/whats_new.json new file mode 100644 index 00000000000..40affb71e98 --- /dev/null +++ b/litellm/proxy/public_endpoints/whats_new.json @@ -0,0 +1,25 @@ +{ + "launches": [ + { + "icon": "box", + "title": "Claude Haiku 5.5", + "description": "Anthropic's newest small model, with day 0 pricing on Anthropic, Bedrock and Vertex AI.", + "href": "https://docs.litellm.ai/blog/claude-haiku-5-5", + "published_on": "2026-10-07" + }, + { + "icon": "scale", + "title": "Decision Models", + "description": "Call decision models at /v1/decisions and try them in the Decisions playground.", + "href": "https://docs.litellm.ai/docs/decisions", + "published_on": "2026-10-07" + }, + { + "icon": "zap", + "title": "OpenAI Ultrafast", + "description": "service_tier: ultrafast on GPT-6 Astra and GPT-6.1 Sol, including /ultrafast in Codex.", + "href": "https://docs.litellm.ai/docs/providers/openai/ultrafast", + "published_on": "2026-10-06" + } + ] +} diff --git a/litellm/types/proxy/public_endpoints/public_endpoints.py b/litellm/types/proxy/public_endpoints/public_endpoints.py index c487ce8b6c5..6e4adb4bbe7 100644 --- a/litellm/types/proxy/public_endpoints/public_endpoints.py +++ b/litellm/types/proxy/public_endpoints/public_endpoints.py @@ -1,4 +1,5 @@ from collections.abc import Mapping, Sequence +from datetime import date from typing import Any, Literal from pydantic import ConfigDict @@ -113,6 +114,24 @@ class AutoRouterPresetRecord(LiteLLMBaseModel): complexity_router_config: AutoRouterPresetConfig +class WhatsNewLaunch(LiteLLMBaseModel): + """One launch card in the dashboard Home page's What's new section.""" + + model_config = ConfigDict(frozen=True) + + icon: str + title: str + description: str + href: str + published_on: date + + +class WhatsNewResponse(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + + launches: tuple[WhatsNewLaunch, ...] + + class ComplexityScorerDefaults(LiteLLMBaseModel): """The complexity router's shipped heuristic scorer defaults. diff --git a/pyproject.toml b/pyproject.toml index 925e0c4f08f..e59e452650b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -321,6 +321,7 @@ include = [ "litellm/router_strategy/complexity_router/artifacts/*.json", "litellm/router_strategy/complexity_router/fuse_presets.json", "litellm/proxy/model_insights_tasks.json", + "litellm/proxy/public_endpoints/whats_new.json", "litellm/proxy/common_utils/codex_base_instructions.md", "litellm/proxy/common_utils/codex_bundled_models_0.159.3.json", "litellm/proxy/lens/prompts/*.md", diff --git a/tests/unit/proxy/public_endpoints/test_blog_posts_endpoint.py b/tests/unit/proxy/public_endpoints/test_blog_posts_endpoint.py index c206a91bd81..dbbc31279c3 100644 --- a/tests/unit/proxy/public_endpoints/test_blog_posts_endpoint.py +++ b/tests/unit/proxy/public_endpoints/test_blog_posts_endpoint.py @@ -45,8 +45,8 @@ def test_get_blog_posts_returns_response_shape(client): assert post["url"] == "https://www.litellm.ai/blog/test" -def test_get_blog_posts_limits_to_five(client): - """Endpoint returns at most 5 posts.""" +def test_get_blog_posts_returns_every_fetched_post(client): + """The parser owns the cap (BLOG_POSTS_MAX), so the endpoint passes every fetched post through.""" many_posts = [ { "title": f"Post {i}", @@ -64,7 +64,7 @@ def test_get_blog_posts_limits_to_five(client): response = client.get("/public/litellm_blog_posts") assert response.status_code == 200 - assert len(response.json()["posts"]) == 5 + assert [p["title"] for p in response.json()["posts"]] == [f"Post {i}" for i in range(10)] def test_get_blog_posts_returns_local_backup_on_failure(client): diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py index b720ba24c3e..0779f692b17 100644 --- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py @@ -1,6 +1,8 @@ import json import re from datetime import datetime, timezone +from importlib.resources import files +from collections.abc import Iterator from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -17,7 +19,7 @@ from litellm.router_strategy.complexity_router.fuse_presets import get_fuse_pres from litellm.types.proxy.management_endpoints.model_management_endpoints import ( ModelGroupInfoProxy, ) -from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo +from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo, WhatsNewResponse from litellm.types.utils import LlmProviders @@ -67,13 +69,10 @@ def test_get_provider_create_fields(): assert isinstance(first_provider["credential_fields"], list) has_detailed_fields = any( - provider.get("credential_fields") - and len(provider.get("credential_fields", [])) > 0 + provider.get("credential_fields") and len(provider.get("credential_fields", [])) > 0 for provider in response_data ) - assert ( - has_detailed_fields - ), "Expected at least one provider to have detailed credential fields" + assert has_detailed_fields, "Expected at least one provider to have detailed credential fields" def test_get_litellm_model_cost_map_catalog_only_excludes_runtime_registered_entries( @@ -124,10 +123,7 @@ def test_get_litellm_model_cost_map_returns_cost_map(): sample_model_data = payload[sample_model] assert isinstance(sample_model_data, dict) # Check for common cost fields that should be present - assert ( - "input_cost_per_token" in sample_model_data - or "output_cost_per_token" in sample_model_data - ) + assert "input_cost_per_token" in sample_model_data or "output_cost_per_token" in sample_model_data def test_public_ai_hub_info_is_public_by_default(monkeypatch): @@ -218,9 +214,9 @@ def test_anthropic_provider_fields_support_byok(): "Anthropic api_key must be optional so admins can configure BYOK models " "without entering a key. See BYOK tutorial." ) - assert fields_by_key["api_key"].get( - "tooltip" - ), "Anthropic api_key must have a tooltip explaining the BYOK use case." + assert fields_by_key["api_key"].get("tooltip"), ( + "Anthropic api_key must have a tooltip explaining the BYOK use case." + ) assert "api_base" in fields_by_key, ( "Anthropic provider form must expose api_base so cloud customers " "can override the upstream URL without env var access." @@ -228,16 +224,14 @@ def test_anthropic_provider_fields_support_byok(): api_base_field = fields_by_key["api_base"] assert api_base_field["required"] is False assert api_base_field["field_type"] == "text" - assert api_base_field.get( - "tooltip" - ), "api_base should have a tooltip explaining it is optional." + assert api_base_field.get("tooltip"), "api_base should have a tooltip explaining it is optional." # UI forms render fields in credential_fields order; api_base should come first # so an admin sees the URL override before the key field. field_order = [f["key"] for f in anthropic["credential_fields"]] - assert field_order.index("api_base") < field_order.index( - "api_key" - ), "api_base must appear before api_key in credential_fields (matches AI21 and ANTHROPIC_TEXT convention)." + assert field_order.index("api_base") < field_order.index("api_key"), ( + "api_base must appear before api_key in credential_fields (matches AI21 and ANTHROPIC_TEXT convention)." + ) def test_bedrock_mantle_provider_fields(): @@ -554,9 +548,7 @@ def test_google_ai_studio_provider_fields_expose_api_base(): assert response.status_code == 200 providers = response.json() - google_ai = next( - (p for p in providers if p["provider"] == "Google_AI_Studio"), None - ) + google_ai = next((p for p in providers if p["provider"] == "Google_AI_Studio"), None) assert google_ai is not None, "Google_AI_Studio provider entry not found" assert google_ai["litellm_provider"] == "gemini" @@ -576,18 +568,15 @@ def test_google_ai_studio_provider_fields_expose_api_base(): # placeholder shows the canonical URL so users still get the visual hint. # (See greptileai threads on PR #30419.) assert api_base_field["default_value"] is None - assert ( - api_base_field["placeholder"] - == "https://generativelanguage.googleapis.com/v1beta" - ) + assert api_base_field["placeholder"] == "https://generativelanguage.googleapis.com/v1beta" # UI forms render fields in credential_fields order; api_base should come # first so an admin sees the URL override before the key field (matches # OpenAI and Anthropic conventions). field_order = [f["key"] for f in google_ai["credential_fields"]] - assert field_order.index("api_base") < field_order.index( - "api_key" - ), "api_base must appear before api_key in credential_fields." + assert field_order.index("api_base") < field_order.index("api_key"), ( + "api_base must appear before api_key in credential_fields." + ) def test_public_model_hub_with_healthy_model(): @@ -615,20 +604,15 @@ def test_public_model_hub_with_healthy_model(): mock_llm_router = MagicMock() mock_prisma = MagicMock() - mock_prisma.get_all_latest_health_checks = AsyncMock( - return_value=[mock_health_check] - ) + mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[mock_health_check]) with ( patch("litellm.public_model_groups", ["gpt-3.5-turbo"]), patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info, patch("litellm.proxy.proxy_server.llm_router", mock_llm_router), patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), - patch( - "litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict" - ) as mock_convert, + patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert, ): - mock_get_info.return_value = [mock_model_group] mock_convert.return_value = { "status": "healthy", @@ -673,20 +657,15 @@ def test_public_model_hub_with_unhealthy_model(): mock_llm_router = MagicMock() mock_prisma = MagicMock() - mock_prisma.get_all_latest_health_checks = AsyncMock( - return_value=[mock_health_check] - ) + mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[mock_health_check]) with ( patch("litellm.public_model_groups", ["gpt-4"]), patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info, patch("litellm.proxy.proxy_server.llm_router", mock_llm_router), patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), - patch( - "litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict" - ) as mock_convert, + patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert, ): - mock_get_info.return_value = [mock_model_group] mock_convert.return_value = { "status": "unhealthy", @@ -732,7 +711,6 @@ def test_public_model_hub_without_health_check(): patch("litellm.proxy.proxy_server.llm_router", mock_llm_router), patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), ): - mock_get_info.return_value = [mock_model_group] response = client.get( @@ -789,9 +767,7 @@ def test_public_model_hub_mixed_health_statuses(): mock_llm_router = MagicMock() mock_prisma = MagicMock() - mock_prisma.get_all_latest_health_checks = AsyncMock( - return_value=[healthy_check, unhealthy_check] - ) + mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[healthy_check, unhealthy_check]) def convert_side_effect(check): if check.model_name == "gpt-3.5-turbo": @@ -813,11 +789,8 @@ def test_public_model_hub_mixed_health_statuses(): patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info, patch("litellm.proxy.proxy_server.llm_router", mock_llm_router), patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), - patch( - "litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict" - ) as mock_convert, + patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert, ): - mock_get_info.return_value = [ healthy_model, unhealthy_model, @@ -1066,9 +1039,7 @@ def test_get_supported_endpoints_provider_fields(reset_endpoints_cache): def test_get_supported_endpoints_paths_start_with_slash(reset_endpoints_cache): endpoints = _make_client().get("/public/endpoints").json()["endpoints"] for item in endpoints: - assert item["endpoint"].startswith( - "/" - ), f"Expected path starting with /, got: {item['endpoint']}" + assert item["endpoint"].startswith("/"), f"Expected path starting with /, got: {item['endpoint']}" def test_get_supported_endpoints_chat_completions_present(reset_endpoints_cache): @@ -1092,9 +1063,9 @@ def test_get_supported_endpoints_display_names_have_no_slug_suffix( endpoints = _make_client().get("/public/endpoints").json()["endpoints"] for item in endpoints: for provider in item["providers"]: - assert not suffix_re.search( - provider["display_name"] - ), f"display_name still contains slug suffix: {provider['display_name']!r}" + assert not suffix_re.search(provider["display_name"]), ( + f"display_name still contains slug suffix: {provider['display_name']!r}" + ) def test_get_supported_endpoints_is_cached(reset_endpoints_cache): @@ -1320,7 +1291,6 @@ def test_public_mcp_hub_does_not_expose_upstream_url(): app.dependency_overrides.clear() - @pytest.fixture def reset_autorouter_presets_cache(): from litellm.proxy.public_endpoints.public_endpoints import _AutoRouterPresetsCache @@ -1332,9 +1302,7 @@ def reset_autorouter_presets_cache(): _AutoRouterPresetsCache.lock = None -def test_get_autorouter_presets_local_mode_serves_bundled_catalog( - monkeypatch, reset_autorouter_presets_cache -): +def test_get_autorouter_presets_local_mode_serves_bundled_catalog(monkeypatch, reset_autorouter_presets_cache): monkeypatch.setenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", "True") app = FastAPI() app.include_router(router) @@ -1362,9 +1330,7 @@ def test_get_autorouter_presets_local_mode_serves_bundled_catalog( @pytest.mark.asyncio -async def test_get_autorouter_presets_fetches_once_per_process( - monkeypatch, reset_autorouter_presets_cache -): +async def test_get_autorouter_presets_fetches_once_per_process(monkeypatch, reset_autorouter_presets_cache): from litellm.proxy.public_endpoints.public_endpoints import ( _AUTOROUTER_PRESETS_ADAPTER, get_autorouter_presets, @@ -1376,7 +1342,9 @@ async def test_get_autorouter_presets_fetches_once_per_process( "remote_only": { "label": "Remote Only", "description": "from the remote catalog", - "complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}}, + "complexity_router_config": { + "tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]} + }, } } ) @@ -1411,7 +1379,9 @@ async def test_get_autorouter_presets_single_flight_on_concurrent_cold_start( "remote_only": { "label": "Remote Only", "description": "from the remote catalog", - "complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}}, + "complexity_router_config": { + "tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]} + }, } } ) @@ -1507,9 +1477,7 @@ async def test_autorouter_presets_adapter_rejects_wrong_shapes(): ) -def test_get_autorouter_presets_passes_unknown_catalog_fields_through( - monkeypatch, reset_autorouter_presets_cache -): +def test_get_autorouter_presets_passes_unknown_catalog_fields_through(monkeypatch, reset_autorouter_presets_cache): from litellm.proxy.public_endpoints.public_endpoints import ( _AUTOROUTER_PRESETS_ADAPTER, _AutoRouterPresetsCache, @@ -1551,12 +1519,14 @@ async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypa "remote_only": { "label": "Remote Only", "description": "from the remote catalog", - "complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}}, + "complexity_router_config": { + "tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]} + }, } } response = MagicMock() response.raise_for_status = MagicMock() - response.json = MagicMock(return_value=catalog) + response.content = json.dumps(catalog).encode() client = MagicMock() client.get = AsyncMock(return_value=response) monkeypatch.setattr(http_handler_module, "get_async_httpx_client", lambda llm_provider: client) @@ -1565,6 +1535,121 @@ async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypa assert presets["remote_only"].label == "Remote Only" response.raise_for_status.assert_called_once() - response.json = MagicMock(return_value={}) + response.content = b"{}" with pytest.raises(ValueError, match="empty"): await _fetch_remote_autorouter_presets("https://example.test/presets.json") + + +@pytest.fixture +def reset_whats_new_cache() -> Iterator[None]: + from litellm.proxy.public_endpoints.public_endpoints import _WhatsNewCache + + _WhatsNewCache.launches = None + _WhatsNewCache.lock = None + yield + _WhatsNewCache.launches = None + _WhatsNewCache.lock = None + + +def _remote_whats_new() -> WhatsNewResponse: + return WhatsNewResponse.model_validate( + { + "launches": [ + { + "icon": "sparkles", + "title": "Remote Launch", + "description": "from the remote list", + "href": "https://docs.litellm.ai/blog/remote", + "published_on": "2026-10-11", + } + ] + } + ) + + +def test_get_whats_new_local_mode_serves_bundled_list( + monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None +) -> None: + monkeypatch.setenv("LITELLM_LOCAL_WHATS_NEW", "True") + app = FastAPI() + app.include_router(router) + client = TestClient(app) + + response = client.get("/public/whats_new") + + assert response.status_code == 200 + served: Final = WhatsNewResponse.model_validate_json(response.content) + bundled: Final = WhatsNewResponse.model_validate_json( + files("litellm.proxy.public_endpoints").joinpath("whats_new.json").read_text(encoding="utf-8") + ) + assert served == bundled + assert len(served.launches) > 0 + + +@pytest.mark.asyncio +async def test_get_whats_new_fetches_once_per_process( + monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None +) -> None: + from litellm.proxy.public_endpoints.public_endpoints import get_whats_new + + monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False) + remote = _remote_whats_new() + calls: Final[list[str]] = [] + + async def fake_fetch(url: str) -> WhatsNewResponse: + calls.append(url) + return remote + + first = await get_whats_new(url="https://example.test/whats_new.json", fetch=fake_fetch) + second = await get_whats_new(url="https://example.test/whats_new.json", fetch=fake_fetch) + + assert first == remote + assert second == remote + assert calls == ["https://example.test/whats_new.json"] + + +@pytest.mark.asyncio +async def test_get_whats_new_caches_bundled_fallback_when_remote_is_unreachable( + monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None +) -> None: + from litellm.proxy.public_endpoints.public_endpoints import _load_bundled_whats_new, get_whats_new + + monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False) + calls: Final[list[str]] = [] + + async def unreachable_fetch(url: str) -> WhatsNewResponse: + calls.append(url) + raise OSError("network is unreachable") + + first = await get_whats_new(url="https://example.test/whats_new.json", fetch=unreachable_fetch) + second = await get_whats_new(url="https://example.test/whats_new.json", fetch=unreachable_fetch) + + assert first == _load_bundled_whats_new() + assert second == first + assert len(calls) == 1 + + +@pytest.mark.asyncio +async def test_fetch_remote_whats_new_rejects_a_malformed_list_so_the_bundled_one_is_served( + monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None +) -> None: + import httpx + + from litellm.proxy.public_endpoints import public_endpoints + + monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False) + request = httpx.Request("GET", "https://example.test/whats_new.json") + client = MagicMock() + client.get = AsyncMock( + return_value=httpx.Response( + 200, + request=request, + content=b'{"launches": [{"icon": "box", "title": "No date", "description": "d", "href": "https://x"}]}', + ) + ) + + with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client", return_value=client): + served = await public_endpoints.get_whats_new(url="https://example.test/whats_new.json") + + assert served == public_endpoints._load_bundled_whats_new() + client.get.assert_awaited_once_with("https://example.test/whats_new.json", timeout=5.0) diff --git a/tests/unit/test_get_blog_posts.py b/tests/unit/test_get_blog_posts.py index 241dce23633..5686f45efd3 100644 --- a/tests/unit/test_get_blog_posts.py +++ b/tests/unit/test_get_blog_posts.py @@ -2,17 +2,18 @@ import time from unittest.mock import MagicMock, patch +from xml.etree import ElementTree import pytest import litellm from litellm.litellm_core_utils.get_blog_posts import ( + BLOG_POSTS_MAX, BlogPost, BlogPostsResponse, GetBlogPosts, get_blog_posts, ) -from xml.etree import ElementTree SAMPLE_RSS = """\ @@ -71,6 +72,18 @@ def test_parse_rss_to_posts_multiple(): assert posts[1]["title"] == "Second Post" +def test_parse_rss_to_posts_default_cap(): + items = "".join( + f"Post {i}https://docs.litellm.ai/blog/{i}" + f"DThu, 0{1 + i % 9} Jan 2026 00:00:00 GMT" + for i in range(BLOG_POSTS_MAX + 1) + ) + posts = GetBlogPosts.parse_rss_to_posts(f"{items}") + assert len(posts) == BLOG_POSTS_MAX + assert posts[0]["title"] == "Post 0" + assert posts[-1]["title"] == f"Post {BLOG_POSTS_MAX - 1}" + + def test_parse_rss_to_posts_invalid_xml(): with pytest.raises(ElementTree.ParseError): GetBlogPosts.parse_rss_to_posts("not xml") @@ -82,9 +95,7 @@ def test_parse_rss_to_posts_missing_channel(): def test_validate_blog_posts_valid(): - posts = [ - {"title": "T", "description": "D", "date": "2026-01-01", "url": "https://x.com"} - ] + posts = [{"title": "T", "description": "D", "date": "2026-01-01", "url": "https://x.com"}] assert GetBlogPosts.validate_blog_posts(posts) is True @@ -108,7 +119,7 @@ def test_get_blog_posts_success(): ): posts = get_blog_posts(url=litellm.blog_posts_url) - assert len(posts) == 1 + assert len(posts) == 2 assert posts[0]["title"] == "Test Post" @@ -163,9 +174,7 @@ def test_get_blog_posts_ttl_cache_not_refetched(): m.raise_for_status = MagicMock() return m - with patch( - "litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=mock_get - ): + with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=mock_get): posts = get_blog_posts(url=litellm.blog_posts_url) assert call_count == 0 # cache hit, no fetch @@ -196,7 +205,7 @@ def test_get_blog_posts_ttl_expired_refetches(): posts = get_blog_posts(url=litellm.blog_posts_url) mock_get.assert_called_once() - assert len(posts) == 1 + assert len(posts) == 2 def test_get_blog_posts_local_env_var_skips_remote(monkeypatch): @@ -219,9 +228,5 @@ def test_blog_post_pydantic_model(): def test_blog_posts_response_pydantic_model(): - resp = BlogPostsResponse( - posts=[ - BlogPost(title="T", description="D", date="2026-01-01", url="https://x.com") - ] - ) + resp = BlogPostsResponse(posts=[BlogPost(title="T", description="D", date="2026-01-01", url="https://x.com")]) assert len(resp.posts) == 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx new file mode 100644 index 00000000000..a17a18beee5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx @@ -0,0 +1,190 @@ +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DailyActivityAggregatedResponse } from "@/components/UsagePage/dailyActivityApi"; +import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi"; +import { all_admin_roles } from "@/utils/roles"; +import HomePage from "./HomePage"; + +const auth = vi.hoisted(() => ({ + accessToken: "sk-test", + userRole: "", + userId: "u1", + isViewOnly: false, +})); +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => auth })); + +const network = vi.hoisted(() => ({ + aggregated: vi.fn(), + gateway: vi.fn(), + userInfo: vi.fn(), +})); +vi.mock("@/components/networking", async (importOriginal) => ({ + ...(await importOriginal()), + dailyActivityAggregatedCall: (...args: unknown[]) => network.aggregated(...args), + gatewayDailyActivityCall: (...args: unknown[]) => network.gateway(...args), + userGetInfoV2: (...args: unknown[]) => network.userInfo(...args), + getProxyBaseUrl: () => "http://proxy.test", +})); + +const blogPosts = { posts: [{ title: "Post one", description: "d", date: "2026-10-09", url: "https://x/1" }] }; + +const launch = (title: string, icon: string, published_on: string) => ({ + icon, + title, + description: `${title} description`, + href: `https://docs.litellm.ai/blog/${title}`, + published_on, +}); + +const whatsNew = vi.hoisted(() => ({ launches: [] as unknown[] })); + +const requestUrl = (input: RequestInfo | URL): string => { + if (typeof input === "string") return input; + return input instanceof URL ? input.href : input.url; +}; + +const fakeFetch = (input: RequestInfo | URL) => { + const body = requestUrl(input).endsWith("/public/whats_new") ? whatsNew : blogPosts; + return Promise.resolve( + new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }), + ); +}; + +const dayResult = (date: string, model: string, tokens: number) => { + const metrics = { + spend: 1, + prompt_tokens: tokens, + completion_tokens: 0, + total_tokens: tokens, + api_requests: 1, + successful_requests: 1, + failed_requests: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }; + return { + date, + metrics, + breakdown: { + models: {}, + model_groups: { [model]: { metrics, metadata: {}, api_key_breakdown: {} } }, + providers: {}, + api_keys: {}, + entities: {}, + mcp_servers: {}, + }, + }; +}; + +const activity: DailyActivityAggregatedResponse = { + results: [ + dayResult("2026-10-08", "gpt-6.1-sol", 900), + dayResult("2026-10-09", "claude-haiku-5-5", 100), + ] as unknown as DailyActivityAggregatedResponse["results"], + metadata: { + ...EMPTY_DAILY_ACTIVITY_METADATA, + total_spend: 501.74, + total_api_requests: 5118, + total_successful_requests: 5075, + total_failed_requests: 43, + total_tokens: 98_600_000, + }, +}; + +const renderHome = () => + render( + + + , + ); + +describe("HomePage", () => { + beforeEach(() => { + auth.isViewOnly = false; + auth.userRole = all_admin_roles[0]!; + network.aggregated.mockReset().mockResolvedValue(activity); + network.gateway.mockReset().mockResolvedValue({ total_successful_requests: 0, total_failed_requests: 0 }); + network.userInfo.mockReset().mockResolvedValue({ user_info: { max_budget: null } }); + whatsNew.launches = [launch("mid", "box", "2026-10-06"), launch("new", "zap", "2026-10-11")]; + vi.stubGlobal("fetch", vi.fn(fakeFetch)); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("shows the usage totals from the daily activity API and links Create New Virtual Key to the key creation flow", async () => { + renderHome(); + expect(screen.getByRole("heading", { level: 1, name: "Home" })).toBeInTheDocument(); + expect(await screen.findByText("$501.74")).toBeInTheDocument(); + expect(screen.getByText("5,118")).toBeInTheDocument(); + expect(screen.getByText("98.6M")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: /create new virtual key/i })).toHaveAttribute( + "href", + expect.stringMatching(/\/api-keys\?create=true$/), + ); + expect(network.aggregated).toHaveBeenCalledWith("user", expect.objectContaining({ accessToken: "sk-test" })); + }); + + it("hides Create New Virtual Key from view-only users", () => { + auth.isViewOnly = true; + renderHome(); + expect(screen.queryByRole("link", { name: /create new virtual key/i })).not.toBeInTheDocument(); + }); + + it("shows an error with a retry instead of zero totals when the usage request fails", async () => { + network.aggregated.mockRejectedValueOnce(new Error("boom")); + renderHome(); + expect(await screen.findByText("Failed to load usage")).toBeInTheDocument(); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + expect(await screen.findByText("$501.74")).toBeInTheDocument(); + expect(network.aggregated).toHaveBeenCalledTimes(2); + }); + + it("renders the launches served by /public/whats_new newest first, whatever order the JSON lists them in", async () => { + whatsNew.launches = [ + launch("mid", "box", "2026-10-06"), + launch("new", "an-icon-from-a-newer-list", "2026-10-11"), + launch("old", "scale", "2026-09-30"), + ]; + renderHome(); + const section = await screen.findByTestId("home-whats-new"); + expect(await within(section).findByText("new")).toBeInTheDocument(); + expect( + within(section) + .getAllByRole("link") + .map((link) => link.getAttribute("href")), + ).toEqual([ + "https://docs.litellm.ai/blog/new", + "https://docs.litellm.ai/blog/mid", + "https://docs.litellm.ai/blog/old", + ]); + expect(within(section).getByText("Oct 11")).toBeInTheDocument(); + }); + + it("hides What's new when /public/whats_new has no launches", async () => { + whatsNew.launches = []; + renderHome(); + expect(await screen.findByRole("link", { name: /post one/i })).toBeInTheDocument(); + expect(screen.queryByTestId("home-whats-new")).not.toBeInTheDocument(); + }); + + it("ranks model groups by token share on the leaderboard", async () => { + renderHome(); + const board = screen.getByTestId("home-leaderboard"); + expect(await within(board).findByText("gpt-6.1-sol")).toBeInTheDocument(); + const rows = within(board).getAllByRole("listitem"); + expect(within(rows[0]!).getByText("gpt-6.1-sol")).toBeInTheDocument(); + expect(within(rows[0]!).getByText("90.0%")).toBeInTheDocument(); + expect(within(rows[1]!).getByText("claude-haiku-5-5")).toBeInTheDocument(); + }); + + it("renders the blog posts fetched from /public/litellm_blog_posts in the Updates feed", async () => { + renderHome(); + const updates = screen.getByTestId("home-updates"); + expect(await within(updates).findByRole("link", { name: /post one/i })).toHaveAttribute("href", "https://x/1"); + expect(fetch).toHaveBeenCalledWith("http://proxy.test/public/litellm_blog_posts"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx new file mode 100644 index 00000000000..bcae30795c6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { ArrowRight, Plus, Sparkles } from "lucide-react"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Page } from "@/components/shared/Page"; +import { PageHeader, PageHeaderControls, PageHeaderTitle } from "@/components/shared/PageHeader"; +import { uiHref } from "@/utils/uiHref"; +import { Leaderboard, useBreakdown } from "@/app/(dashboard)/usage/_components/components/overview/BreakdownChart"; +import { Panel } from "@/app/(dashboard)/usage/_components/components/overview/Primitives"; +import UsageStatStrip from "@/app/(dashboard)/usage/_components/components/overview/UsageStatStrip"; +import { formatPublishedOn, launchIcon } from "./homeContent"; +import UpdatesFeed from "./UpdatesFeed"; +import { HOME_USAGE_DAYS, useHomeUsage } from "./useHomeUsage"; +import { useWhatsNew } from "./useWhatsNew"; + +const LEADERBOARD = { metric: "tokens", dimension: "model_groups" } as const; + +export default function HomePage() { + const { isViewOnly } = useAuthorized(); + const usage = useHomeUsage(); + const whatsNew = useWhatsNew(); + const { series, ranking } = useBreakdown(usage.results, LEADERBOARD, 8); + + return ( + + + Home + {!isViewOnly && ( + + + + )} + + +
+ {usage.failed ? ( +
+ Failed to load usage + +
+ ) : ( + + )} +
+ +
+
+ {(whatsNew.isLoading || (whatsNew.data?.length ?? 0) > 0) && ( +
+
+

+ + What's new in LiteLLM? +

+
+
+ {whatsNew.isLoading && + [0, 1, 2].map((i) => ( + + ))} + {whatsNew.data?.map((item) => { + const Icon = launchIcon(item.icon); + return ( + + +
+ +
+
{item.title}
+

{item.description}

+
+ {formatPublishedOn(item.published_on)} + +
+
+
+ ); + })} +
+
+ )} + + + + +
+ + +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/UpdatesFeed.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/UpdatesFeed.tsx new file mode 100644 index 00000000000..ddb135d6f4d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/UpdatesFeed.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { ArrowUpRight, Newspaper } from "lucide-react"; +import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; +import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; + +export const BLOG_URL = "https://docs.litellm.ai/blog"; +const MAX_ROWS = 6; +const DAY_MS = 86_400_000; + +const relativeTime = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + +export const formatRelativeDate = (isoDate: string, now: Date = new Date()): string => { + const published = new Date(`${isoDate}T00:00:00`); + if (Number.isNaN(published.getTime())) return isoDate; + const days = Math.max(0, Math.round((now.getTime() - published.getTime()) / DAY_MS)); + if (days < 7) return relativeTime.format(-days, "day"); + if (days < 30) return relativeTime.format(-Math.round(days / 7), "week"); + if (days < 365) return relativeTime.format(-Math.round(days / 30), "month"); + return relativeTime.format(-Math.round(days / 365), "year"); +}; + +const Row = ({ children }: { children: React.ReactNode }) => ( +
  • + + + +
    {children}
    +
  • +); + +/** Latest posts from the LiteLLM blog RSS feed, via /public/litellm_blog_posts, one row per post. */ +export default function UpdatesFeed({ className }: { className?: string }) { + const disabled = useDisableBlogPosts(); + const { data, isLoading, isError, refetch } = useBlogPosts(); + if (disabled) return null; + + const posts = data?.posts.slice(0, MAX_ROWS) ?? []; + + return ( +
    +
    +

    + Updates +

    + +
    + {isLoading && ( + + )} + {isError && ( +
    + Failed to load posts + +
    + )} + {data && posts.length === 0 &&

    No posts yet

    } + {posts.length > 0 && ( +
      + {posts.map((post: BlogPost) => ( + + {formatRelativeDate(post.date)} + + {post.title} + + {post.description} + + ))} +
    + )} +
    + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.test.ts new file mode 100644 index 00000000000..7fad5767d7f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.test.ts @@ -0,0 +1,40 @@ +import { Box, Sparkles } from "lucide-react"; +import { describe, expect, it } from "vitest"; +import { latestLaunches, launchIcon, type WhatsNewLaunch } from "./homeContent"; + +const launch = (title: string, published_on: string): WhatsNewLaunch => ({ + icon: "box", + title, + description: "d", + href: `https://docs.litellm.ai/${title}`, + published_on, +}); + +describe("latestLaunches", () => { + it("orders launches by published_on, newest first, without mutating the input", () => { + const launches = [launch("old", "2026-09-01"), launch("new", "2026-10-11"), launch("mid", "2026-10-01")]; + expect(latestLaunches(launches).map((l) => l.title)).toEqual(["new", "mid", "old"]); + expect(launches.map((l) => l.title)).toEqual(["old", "new", "mid"]); + }); + + it("keeps only the three newest so the cards fill one row", () => { + const launches = [ + launch("oldest", "2026-08-01"), + launch("a", "2026-10-03"), + launch("b", "2026-10-02"), + launch("c", "2026-10-01"), + ]; + expect(latestLaunches(launches).map((l) => l.title)).toEqual(["a", "b", "c"]); + }); +}); + +describe("launchIcon", () => { + it("maps a known icon name to its lucide icon", () => { + expect(launchIcon("box")).toBe(Box); + }); + + it("falls back to Sparkles for an icon name this dashboard version does not know", () => { + expect(launchIcon("hologram")).toBe(Sparkles); + expect(launchIcon("constructor")).toBe(Sparkles); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts new file mode 100644 index 00000000000..c3eb4b2c838 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts @@ -0,0 +1,26 @@ +import { Bot, Box, Gauge, Plug, Rocket, Scale, Shield, Sparkles, Zap, type LucideIcon } from "lucide-react"; +import type { components } from "@/lib/http/schema"; + +export type WhatsNewLaunch = components["schemas"]["WhatsNewLaunch"]; + +const LAUNCH_ICONS: ReadonlyMap = new Map([ + ["bot", Bot], + ["box", Box], + ["gauge", Gauge], + ["plug", Plug], + ["rocket", Rocket], + ["scale", Scale], + ["shield", Shield], + ["sparkles", Sparkles], + ["zap", Zap], +]); + +export const launchIcon = (name: string): LucideIcon => LAUNCH_ICONS.get(name) ?? Sparkles; + +export const WHATS_NEW_MAX = 3; + +export const latestLaunches = (launches: readonly WhatsNewLaunch[]): readonly WhatsNewLaunch[] => + [...launches].sort((a, b) => b.published_on.localeCompare(a.published_on)).slice(0, WHATS_NEW_MAX); + +export const formatPublishedOn = (isoDate: string): string => + new Date(`${isoDate}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useHomeUsage.ts b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useHomeUsage.ts new file mode 100644 index 00000000000..fe6931bb6f5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useHomeUsage.ts @@ -0,0 +1,86 @@ +import { useEffect, useMemo, useState } from "react"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; +import { gatewayDailyActivityCall } from "@/components/networking"; +import { toDailyData } from "@/components/UsagePage/dailyActivityApi"; +import type { DailyData } from "@/components/UsagePage/types"; +import { all_admin_roles } from "@/utils/roles"; +import { useAggregatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity"; +import { ENTITY_API } from "@/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns"; +import type { GatewayActivity } from "@/app/(dashboard)/usage/_components/components/gatewayActivity"; +import { EMPTY_DAILY_ACTIVITY_METADATA, type DailyActivityRequest } from "@/components/UsagePage/dailyActivityApi"; +import { + overviewTotals, + type OverviewTotals, +} from "@/app/(dashboard)/usage/_components/components/overview/overviewData"; + +export const HOME_USAGE_DAYS = 7; + +export interface HomeUsage { + results: readonly DailyData[]; + totals: OverviewTotals; + loading: boolean; + requestCountsPending: boolean; + failed: boolean; + budget: number | null; + retry: () => void; +} + +/** Last HOME_USAGE_DAYS of the signed-in user's view of usage: deployment-wide for admins, their own otherwise. */ +export function useHomeUsage(): HomeUsage { + const { accessToken, userRole, userId } = useAuthorized(); + const { data: currentUser } = useCurrentUser(); + const isAdmin = all_admin_roles.includes(userRole || ""); + const [attempt, setAttempt] = useState(0); + const range = useMemo(() => { + const endTime = new Date(); + const startTime = new Date(endTime.getTime() - HOME_USAGE_DAYS * 24 * 60 * 60 * 1000); + return { startTime, endTime }; + }, []); + + const request = useMemo( + () => + accessToken && (isAdmin || userId) + ? { accessToken, ...range, entityIds: isAdmin ? null : [userId as string] } + : null, + [accessToken, isAdmin, range, userId], + ); + const { data, loading, failed } = useAggregatedDailyActivity({ + fetch: () => ENTITY_API.user.aggregated(request as DailyActivityRequest), + enabled: request !== null, + deps: [accessToken, isAdmin, userId, range.startTime.toISOString(), attempt], + }); + + const [gateway, setGateway] = useState(null); + useEffect(() => { + if (!isAdmin || !accessToken) return; + let cancelled = false; + gatewayDailyActivityCall(accessToken, range.startTime, range.endTime) + .then((activity: GatewayActivity) => { + const recorded = activity.total_successful_requests + activity.total_failed_requests > 0; + if (!cancelled) setGateway(recorded ? activity : null); + }) + .catch(() => { + if (!cancelled) setGateway(null); + }); + return () => { + cancelled = true; + }; + }, [isAdmin, accessToken, range, attempt]); + + const results = useMemo(() => toDailyData(data), [data]); + const totals = useMemo( + () => overviewTotals(data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, gateway), + [data.metadata, gateway], + ); + + return { + results, + totals, + loading, + requestCountsPending: loading && gateway === null, + failed, + budget: currentUser?.max_budget ?? null, + retry: () => setAttempt((n) => n + 1), + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useWhatsNew.ts b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useWhatsNew.ts new file mode 100644 index 00000000000..c2171f3349a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useWhatsNew.ts @@ -0,0 +1,10 @@ +import { $api } from "@/lib/http/api"; +import { latestLaunches } from "./homeContent"; + +export const useWhatsNew = () => + $api.useQuery( + "get", + "/public/whats_new", + {}, + { staleTime: 60 * 60 * 1000, select: (data) => latestLaunches(data.launches) }, + ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/page.tsx new file mode 100644 index 00000000000..06e3b000816 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import LoadingScreen from "@/components/common_components/LoadingScreen"; +import HomePage from "./_components/HomePage"; + +export default function HomeRoutePage() { + const { isLoading, isAuthorized } = useAuthorized(); + if (isLoading || !isAuthorized) { + return ; + } + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts index 09cc4ef0a75..f4489625dfe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts @@ -2,6 +2,7 @@ import { uiHref } from "@/utils/uiHref"; const LEGACY_PAGE_ROUTES: ReadonlyMap = new Map( Object.entries({ + home: "home", "api-keys": "api-keys", models: "models-and-endpoints", api_ref: "api-reference", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx index aaad0d072e5..f5baff0e727 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.test.tsx @@ -37,8 +37,8 @@ vi.mock("@/contexts/AuthContext", () => ({ }), })); vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ useKeys: mockUseKeys })); -vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({ - default: () =>
    , +vi.mock("@/app/(dashboard)/home/_components/HomePage", () => ({ + default: () =>
    , })); vi.mock("@/components/common_components/LoadingScreen", () => ({ default: () =>
    , @@ -86,7 +86,7 @@ describe("dashboard landing", () => { (role) => { state.userRole = role; render(); - expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + expect(screen.getByTestId("home-page")).toBeInTheDocument(); expect(screen.queryByTestId("loading-screen")).not.toBeInTheDocument(); expect(mockReplace).not.toHaveBeenCalled(); expect(mockUiHref).not.toHaveBeenCalledWith("connect"); @@ -96,7 +96,7 @@ describe("dashboard landing", () => { it("lands a user who already owns a key on the keys dashboard", () => { state.keys = [{ token: "sk-abc" }]; render(); - expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + expect(screen.getByTestId("home-page")).toBeInTheDocument(); expect(mockReplace).not.toHaveBeenCalled(); }); @@ -110,7 +110,7 @@ describe("dashboard landing", () => { render(); expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/logs"); expect(screen.getByTestId("loading-screen")).toBeInTheDocument(); - expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument(); + expect(screen.queryByTestId("home-page")).not.toBeInTheDocument(); }); it("carries the MCP env-var deep link's other params through the legacy redirect", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx index 3a38958dd66..94f4ba79a25 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/page.tsx @@ -1,6 +1,6 @@ "use client"; -import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard"; +import HomePage from "@/app/(dashboard)/home/_components/HomePage"; import LoadingScreen from "@/components/common_components/LoadingScreen"; import { proxyBaseUrl } from "@/components/networking"; import { useAuth } from "@/contexts/AuthContext"; @@ -89,7 +89,7 @@ function CreateKeyPageContent() { return ; } - return ; + return ; } export default function CreateKeyPage() { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx index b6623a8bc6c..a1973bc446f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageOverview.tsx @@ -6,7 +6,7 @@ * matching colors. */ -import React, { type ReactNode, useMemo, useState } from "react"; +import { type ReactNode, useMemo, useState } from "react"; import type { DailyData } from "@/components/UsagePage/types"; import { KeyRound } from "lucide-react"; import { StackedUsageChart, type StackedUsageScale } from "@/components/shared/charts"; @@ -14,18 +14,15 @@ import { bucketSeries, bucketTotals, labelForDate, - dailyTotals, - formatCompact, - formatLatency, formatMetricValue, - formatUsd, type Granularity, type OverviewTotals, type UsageMetric, } from "./overviewData"; import { BreakdownControls, Leaderboard, useBreakdown, type BreakdownState } from "./BreakdownChart"; import { cn } from "@/lib/cva.config"; -import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented, Sparkline, Stat } from "./Primitives"; +import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented } from "./Primitives"; +import UsageStatStrip from "./UsageStatStrip"; interface UsageOverviewProps { results: readonly DailyData[]; @@ -40,7 +37,6 @@ interface UsageOverviewProps { } const METRIC_NOUN: Record = { spend: "spend", tokens: "tokens", requests: "requests" }; -const BRAND = "#2b3fd6"; const GRANULARITY_OPTIONS = [ { value: "day", label: "Daily" }, { value: "week", label: "Weekly" }, @@ -67,57 +63,17 @@ export default function UsageOverview({ const { series: dailySeries, ranking } = useBreakdown(results, state, 8); const series = useMemo(() => bucketSeries(dailySeries, granularity), [dailySeries, granularity]); const totalsByBucket = useMemo(() => bucketTotals(series), [series]); - const daily = useMemo(() => dailyTotals(results), [results]); const format = (value: number) => formatMetricValue(value, state.metric); return (
    -
    - }> - - - }> - - {!requestCountsPending && ( -
    - - {totals.successful.toLocaleString()} ok - - - 0 ? "text-destructive tabular-nums" : "tabular-nums"}> - {totals.failed.toLocaleString()} - {" "} - failed - -
    - )} -
    - }> - - - -
    - - -
    -
    -
    + ); } - -function StatCell({ children, trend }: { children: React.ReactNode; trend?: React.ReactNode }) { - return ( -
    -
    {children}
    - {trend} -
    - ); -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx new file mode 100644 index 00000000000..318b7758370 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/overview/UsageStatStrip.tsx @@ -0,0 +1,90 @@ +"use client"; + +import React, { useMemo } from "react"; +import type { DailyData } from "@/components/UsagePage/types"; +import { cn } from "@/lib/cva.config"; +import { dailyTotals, formatCompact, formatLatency, formatUsd, type OverviewTotals } from "./overviewData"; +import { PANEL_INSET_X, Sparkline, Stat } from "./Primitives"; + +const BRAND = "#2b3fd6"; + +interface UsageStatStripProps { + results: readonly DailyData[]; + totals: OverviewTotals; + loading: boolean; + requestCountsPending: boolean; + budget: number | null; +} + +/** Spend, requests, tokens and averages for a range, each with a daily sparkline. */ +export default function UsageStatStrip({ + results, + totals, + loading, + requestCountsPending, + budget, +}: UsageStatStripProps) { + const daily = useMemo(() => dailyTotals(results), [results]); + return ( +
    + }> + + + }> + + {!requestCountsPending && ( +
    + + {totals.successful.toLocaleString()} ok + + + 0 ? "text-destructive tabular-nums" : "tabular-nums"}> + {totals.failed.toLocaleString()} + {" "} + failed + +
    + )} +
    + }> + + + +
    + + +
    +
    +
    + ); +} + +function StatCell({ children, trend }: { children: React.ReactNode; trend?: React.ReactNode }) { + return ( +
    +
    {children}
    + {trend} +
    + ); +} diff --git a/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts b/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts index 5bb61756f80..c05e4db8924 100644 --- a/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts +++ b/ui/litellm-dashboard/src/components/CommandPalette/utils.test.ts @@ -5,7 +5,7 @@ import { flattenNavItems, matchNavItems, paletteScopeForRoute, shortcutLabel, ty describe("paletteScopeForRoute", () => { it.each([ ["api-keys", "keys"], - ["", "keys"], + ["", "global"], ["teams", "global"], ] as const)("uses the expected palette scope for %s", (routeSegment, expected) => { expect(paletteScopeForRoute(routeSegment)).toBe(expected); diff --git a/ui/litellm-dashboard/src/components/CommandPalette/utils.ts b/ui/litellm-dashboard/src/components/CommandPalette/utils.ts index ad02e155b0e..396b92a817e 100644 --- a/ui/litellm-dashboard/src/components/CommandPalette/utils.ts +++ b/ui/litellm-dashboard/src/components/CommandPalette/utils.ts @@ -12,7 +12,7 @@ export interface PaletteNavItem { } export const paletteScopeForRoute = (routeSegment: string): PaletteScope => - routeSegment === "" || routeSegment === "api-keys" ? "keys" : "global"; + routeSegment === "api-keys" ? "keys" : "global"; const matchRank = (item: PaletteNavItem, query: string): number => { const label = item.label.toLowerCase(); diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx index 822cec9deb3..6c64d09009c 100644 --- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx @@ -59,11 +59,11 @@ describe("DashboardHeader breadcrumb", () => { expect(screen.getByText("Models + Endpoints")).toBeInTheDocument(); }); - it("titles the dashboard root as Virtual Keys", () => { + it("titles the dashboard root as Home", () => { state.pathname = "/ui/"; renderDashboardHeader(); - expect(screen.getByText("Virtual Keys")).toBeInTheDocument(); + expect(screen.getByText("Home")).toBeInTheDocument(); }); it("roots the breadcrumb in the AI Gateway selector (with a Chat option) and drops the static section crumb when the selector is available", async () => { diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index cdb74c73a80..7ef951e1041 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -247,6 +247,7 @@ describe("Sidebar (leftnav)", () => { renderWithProviders(); const topLevelLabels = [ + "Home", "Virtual Keys", "Playground", "Models + Endpoints", @@ -660,10 +661,10 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByRole("link", { name: "Virtual Keys" })).not.toHaveAttribute("data-active"); }); - it("marks Virtual Keys active at the dashboard root", () => { + it("marks Home active at the dashboard root", () => { navState.pathname = "/ui/"; renderWithProviders(); - expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("data-active", "true"); + expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute("data-active", "true"); }); it("expands the parent group of the current nested route and marks the child active", () => { @@ -745,8 +746,8 @@ describe("getBreadcrumb", () => { expect(getBreadcrumb("/ui/old-usage")).toEqual({ section: "Developer Tools", title: "Old Usage" }); }); - it("titles the dashboard root as Virtual Keys", () => { - expect(getBreadcrumb("/ui/")).toEqual({ section: "AI Gateway", title: "Virtual Keys" }); + it("titles the dashboard root as Home", () => { + expect(getBreadcrumb("/ui/")).toEqual({ section: "AI Gateway", title: "Home" }); }); it("resolves a nested child route to its parent section", () => { diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 256fe578912..9a6ec334b56 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -40,6 +40,7 @@ import { FlaskConical, Folder, HeartPulse, + House, KeyRound, LayoutGrid, Network, @@ -207,6 +208,7 @@ const menuGroups: MenuGroup[] = [ { groupLabel: "AI GATEWAY", items: [ + { key: "home", page: "home", label: "Home", icon: }, { key: "api-keys", page: "api-keys", label: "Virtual Keys", icon: }, { key: "llm-playground", @@ -468,7 +470,7 @@ const menuGroups: MenuGroup[] = [ }, ]; -const HOME_ROUTE = "api-keys"; +const HOME_ROUTE = "home"; export const routeOf = (item: MenuItem): string => item.route ?? item.page; diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts index 8eb14fc2151..72bed431421 100644 --- a/ui/litellm-dashboard/src/components/page_metadata.ts +++ b/ui/litellm-dashboard/src/components/page_metadata.ts @@ -5,6 +5,7 @@ // Page descriptions for UI Settings configuration export const pageDescriptions: Record = { + home: "Usage at a glance, recent launches and blog updates", "api-keys": "Manage virtual keys for API access and authentication", "llm-playground": "Interactive playground for testing LLM requests", models: "Configure and manage LLM models and endpoints", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index cf9a8087f53..b8a3aee37b9 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -14152,6 +14152,31 @@ export interface paths { patch?: never; trace?: never; }; + "/public/whats_new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Public Whats New + * @description Return the launches the dashboard Home page shows under What's new. + * + * Resolved once per process: fetched from ``litellm.whats_new_url`` (override with ``LITELLM_WHATS_NEW_URL``) + * on the first request, falling back to the list bundled with the package on any failure, so an airgapped + * proxy serves the bundled list without retrying. Set ``LITELLM_LOCAL_WHATS_NEW=True`` to skip the fetch. + * A restart picks up a newly published list. + */ + get: operations["get_public_whats_new_public_whats_new_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/queue/chat/completions": { parameters: { query?: never; @@ -51031,6 +51056,30 @@ export interface components { type: "web_search" | "web_search_2025_08_26"; user_location?: components["schemas"]["openai__types__responses__web_search_tool_param__UserLocation"] | null; }; + /** + * WhatsNewLaunch + * @description One launch card in the dashboard Home page's What's new section. + */ + WhatsNewLaunch: { + /** Description */ + description: string; + /** Href */ + href: string; + /** Icon */ + icon: string; + /** + * Published On + * Format: date + */ + published_on: string; + /** Title */ + title: string; + }; + /** WhatsNewResponse */ + WhatsNewResponse: { + /** Launches */ + launches: components["schemas"]["WhatsNewLaunch"][]; + }; /** Worker */ Worker: { /** Analysis Key Id */ @@ -71403,6 +71452,26 @@ export interface operations { }; }; }; + get_public_whats_new_public_whats_new_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"]["WhatsNewResponse"]; + }; + }; + }; + }; async_queue_request_queue_chat_completions_post: { parameters: { query?: { diff --git a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx index 2c57833102d..1a31d85eed5 100644 --- a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx +++ b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx @@ -105,7 +105,7 @@ vi.mock("@/utils/returnUrlUtils", async (importOriginal) => { // Super-light stubs for all heavy components so rendering doesn't explode vi.mock("@/components/navbar", () => ({ default: stub("navbar") })); -vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({ default: stub("api-keys-dashboard") })); +vi.mock("@/app/(dashboard)/home/_components/HomePage", () => ({ default: stub("home-page") })); vi.mock("@/components/templates/model_dashboard", () => ({ default: stub("model-dashboard") })); vi.mock("@/components/teams", () => ({ default: stub("teams") })); vi.mock("@/app/(dashboard)/organizations/_components/organizations", () => ({ @@ -271,7 +271,7 @@ describe("CreateKeyPage auth behavior", () => { // And the default page content appears (ApiKeysDashboard stub; chrome now lives in the layout) await waitFor(() => { - expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument(); + expect(screen.getByTestId("home-page")).toBeInTheDocument(); }); });