diff --git a/litellm/__init__.py b/litellm/__init__.py index 290d15bd07a..504993cb469 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -441,7 +441,6 @@ 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 | None = os.getenv("LITELLM_WHATS_NEW_URL") 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 0dd09064dcc..c8bc184f972 100644 --- a/litellm/litellm_core_utils/get_blog_posts.py +++ b/litellm/litellm_core_utils/get_blog_posts.py @@ -8,7 +8,6 @@ Disable remote fetching entirely: export LITELLM_LOCAL_BLOG_POSTS=True """ -import json import os import time import xml.etree.ElementTree as ET @@ -17,23 +16,53 @@ from importlib.resources import files from typing import Final import httpx +from pydantic import ConfigDict 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 +BLOG_POSTS_MAX: Final[int] = 50 class BlogPost(LiteLLMBaseModel): + model_config = ConfigDict(frozen=True) + title: str description: str date: str url: str + tags: tuple[str, ...] = () class BlogPostsResponse(LiteLLMBaseModel): - posts: list[BlogPost] + model_config = ConfigDict(frozen=True) + + posts: tuple[BlogPost, ...] + + +def _pub_date(pub_date_el: ET.Element | None) -> str: + if pub_date_el is None or not pub_date_el.text: + return "" + try: + return parsedate_to_datetime(pub_date_el.text).strftime("%Y-%m-%d") + except Exception: + return pub_date_el.text + + +def _parse_item(item: ET.Element) -> BlogPost | None: + title_el: Final = item.find("title") + link_el: Final = item.find("link") + if title_el is None or link_el is None: + return None + desc_el: Final = item.find("description") + return BlogPost( + title=title_el.text or "", + description=(desc_el.text or "") if desc_el is not None else "", + date=_pub_date(item.find("pubDate")), + url=link_el.text or "", + tags=tuple(category.text for category in item.findall("category") if category.text), + ) class GetBlogPosts: @@ -41,19 +70,20 @@ 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_POSTS_MAX blog posts + - Parses the XML and extracts up to BLOG_POSTS_MAX blog posts, with their RSS categories as tags - Caches the result in-process for BLOG_POSTS_TTL_SECONDS (1 hour) - Falls back to the bundled local backup on any failure """ - _cached_posts: list[dict[str, str]] | None = None + _cached_posts: tuple[BlogPost, ...] | None = None _last_fetch_time: float = 0.0 @staticmethod - def load_local_blog_posts() -> list[dict[str, str]]: + def load_local_blog_posts() -> tuple[BlogPost, ...]: """Load the bundled local backup blog posts.""" - content: Final = json.loads(files("litellm").joinpath("blog_posts.json").read_text(encoding="utf-8")) - return content.get("posts", []) + return BlogPostsResponse.model_validate_json( + files("litellm").joinpath("blog_posts.json").read_text(encoding="utf-8") + ).posts @staticmethod def fetch_rss_feed(url: str, timeout: int = 5) -> str: @@ -67,54 +97,24 @@ class GetBlogPosts: return response.text @staticmethod - def parse_rss_to_posts(xml_text: str, max_posts: int = BLOG_POSTS_MAX) -> list[dict[str, str]]: + def parse_rss_to_posts(xml_text: str, max_posts: int = BLOG_POSTS_MAX) -> tuple[BlogPost, ...]: """ - Parse RSS XML and return a list of blog post dicts. + Parse RSS XML into blog posts. - Extracts title, description, date (YYYY-MM-DD), and url from each . + Extracts title, description, date (YYYY-MM-DD), url and the tags from each . """ root: Final = ET.fromstring(xml_text) channel: Final = root.find("channel") if channel is None: raise ValueError("RSS feed missing element") - posts: Final[list[dict[str, str]]] = [] - for item in channel.findall("item"): - if len(posts) >= max_posts: - break - - title_el = item.find("title") - link_el = item.find("link") - desc_el = item.find("description") - pub_date_el = item.find("pubDate") - - if title_el is None or link_el is None: - continue - - # Parse RFC 2822 date to YYYY-MM-DD - date_str = "" - if pub_date_el is not None and pub_date_el.text: - try: - dt = parsedate_to_datetime(pub_date_el.text) - date_str = dt.strftime("%Y-%m-%d") - except Exception: - date_str = pub_date_el.text - - posts.append( - { - "title": title_el.text or "", - "description": desc_el.text or "" if desc_el is not None else "", - "date": date_str, - "url": link_el.text or "", - } - ) - - return posts + parsed: Final = (_parse_item(item) for item in channel.findall("item")) + return tuple(post for post in parsed if post is not None)[:max_posts] @staticmethod - def validate_blog_posts(posts: list[dict[str, str]]) -> bool: - """Return True if posts is a non-empty list.""" - if not isinstance(posts, list) or len(posts) == 0: + def validate_blog_posts(posts: tuple[BlogPost, ...]) -> bool: + """Return True if there is at least one post.""" + if len(posts) == 0: verbose_logger.warning( "LiteLLM: Parsed RSS feed has no valid posts. Falling back to local backup.", ) @@ -122,9 +122,9 @@ class GetBlogPosts: return True @classmethod - def get_blog_posts(cls, url: str) -> list[dict[str, str]]: + def get_blog_posts(cls, url: str) -> tuple[BlogPost, ...]: """ - Return the blog posts list. + Return the blog posts. Uses the in-process cache if within BLOG_POSTS_TTL_SECONDS. Fetches from ``url`` otherwise, falling back to local backup on failure. @@ -156,6 +156,6 @@ class GetBlogPosts: return posts -def get_blog_posts(url: str) -> list[dict[str, str]]: - """Public entry point — returns the blog posts list.""" +def get_blog_posts(url: str) -> tuple[BlogPost, ...]: + """Public entry point, returns the blog posts.""" return GetBlogPosts.get_blog_posts(url=url) diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 26bef3b3a61..1d3516b9ebd 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -6,15 +6,13 @@ 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, ValidationError +from pydantic import TypeAdapter from typing_extensions import ReadOnly, TypedDict import litellm from litellm._logging import verbose_logger from litellm.litellm_core_utils.get_blog_posts import ( - BlogPost, BlogPostsResponse, GetBlogPosts, get_blog_posts, @@ -38,7 +36,6 @@ from litellm.types.proxy.public_endpoints.public_endpoints import ( ProviderCreateInfo, PublicModelHubInfo, SupportedEndpointsResponse, - WhatsNewResponse, ) from litellm.types.utils import LlmProviders @@ -479,21 +476,18 @@ async def get_litellm_model_cost_map(catalog_only: bool = False): tags=["public"], response_model=BlogPostsResponse, ) -async def get_litellm_blog_posts(): +async def get_litellm_blog_posts() -> BlogPostsResponse: """ - Public endpoint to get the latest LiteLLM blog posts. + Public endpoint to get the latest LiteLLM blog posts, each with its RSS categories as ``tags``. - Fetches from GitHub with a 1-hour in-process cache. + Fetches the docs RSS feed with a 1-hour in-process cache. Falls back to the bundled local backup on any failure. """ try: - posts_data = get_blog_posts(url=litellm.blog_posts_url) + return BlogPostsResponse(posts=get_blog_posts(url=litellm.blog_posts_url)) except Exception as e: 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] - return BlogPostsResponse(posts=posts) + return BlogPostsResponse(posts=GetBlogPosts.load_local_blog_posts()) _AUTOROUTER_PRESETS_ADAPTER: Final = TypeAdapter(dict[str, AutoRouterPresetRecord]) @@ -581,70 +575,6 @@ 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 | None, fetch: Callable[[str], Awaitable[WhatsNewResponse]]) -> WhatsNewResponse: - if url is None or 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 | None, - 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. - - Serves the list bundled with this release. Set ``LITELLM_WHATS_NEW_URL`` to fetch a remote list instead, - resolved once per process on the first request and falling back to the bundled list on any failure, so an - airgapped proxy serves the bundled list without retrying. ``LITELLM_LOCAL_WHATS_NEW=True`` forces the - bundled list even when a URL is set. 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/types/proxy/public_endpoints/public_endpoints.py b/litellm/types/proxy/public_endpoints/public_endpoints.py index 6e4adb4bbe7..c487ce8b6c5 100644 --- a/litellm/types/proxy/public_endpoints/public_endpoints.py +++ b/litellm/types/proxy/public_endpoints/public_endpoints.py @@ -1,5 +1,4 @@ from collections.abc import Mapping, Sequence -from datetime import date from typing import Any, Literal from pydantic import ConfigDict @@ -114,24 +113,6 @@ 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/tests/integration/security/_sinks.py b/tests/integration/security/_sinks.py index 1aebaba1621..906384e8760 100644 --- a/tests/integration/security/_sinks.py +++ b/tests/integration/security/_sinks.py @@ -80,7 +80,6 @@ LOCAL_CATALOGS: Final = MappingProxyType( "LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS", "LITELLM_LOCAL_AUTOROUTER_PRESETS", "LITELLM_LOCAL_POLICY_TEMPLATES", - "LITELLM_LOCAL_WHATS_NEW", ) } ) diff --git a/tests/integration/security/test_sweep_sensitivity.py b/tests/integration/security/test_sweep_sensitivity.py index 46c004ad45d..d3377093965 100644 --- a/tests/integration/security/test_sweep_sensitivity.py +++ b/tests/integration/security/test_sweep_sensitivity.py @@ -85,7 +85,7 @@ def test_rig_with_an_overridden_master_key_resolves_the_config_deployment(tmp_pa assert overridden.proxy.key == master_key assert overridden.model_id assert overridden.proxy.request("GET", "/model/info").status_code == 200 - assert overridden.proxy.request("GET", "/public/whats_new").status_code == 200 + assert overridden.proxy.request("GET", "/public/litellm_blog_posts").status_code == 200 assert overridden.egress() == () 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 dbbc31279c3..a771b1ccdb0 100644 --- a/tests/unit/proxy/public_endpoints/test_blog_posts_endpoint.py +++ b/tests/unit/proxy/public_endpoints/test_blog_posts_endpoint.py @@ -5,14 +5,17 @@ from unittest.mock import patch import pytest from fastapi.testclient import TestClient -SAMPLE_POSTS = [ - { - "title": "Test Post", - "description": "A test post.", - "date": "2026-01-01", - "url": "https://www.litellm.ai/blog/test", - } -] +from litellm.litellm_core_utils.get_blog_posts import BlogPost, GetBlogPosts + +SAMPLE_POSTS = ( + BlogPost( + title="Test Post", + description="A test post.", + date="2026-01-01", + url="https://www.litellm.ai/blog/test", + tags=("anthropic", "day 0 support"), + ), +) @pytest.fixture @@ -35,27 +38,25 @@ def test_get_blog_posts_returns_response_shape(client): response = client.get("/public/litellm_blog_posts") assert response.status_code == 200 - data = response.json() - assert "posts" in data - assert len(data["posts"]) == 1 - post = data["posts"][0] - assert post["title"] == "Test Post" - assert post["description"] == "A test post." - assert post["date"] == "2026-01-01" - assert post["url"] == "https://www.litellm.ai/blog/test" + assert response.json() == { + "posts": [ + { + "title": "Test Post", + "description": "A test post.", + "date": "2026-01-01", + "url": "https://www.litellm.ai/blog/test", + "tags": ["anthropic", "day 0 support"], + } + ] + } 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}", - "description": "desc", - "date": "2026-01-01", - "url": f"https://www.litellm.ai/blog/{i}", - } + many_posts = tuple( + BlogPost(title=f"Post {i}", description="desc", date="2026-01-01", url=f"https://www.litellm.ai/blog/{i}") for i in range(10) - ] + ) with patch( "litellm.proxy.public_endpoints.public_endpoints.get_blog_posts", @@ -68,14 +69,14 @@ def test_get_blog_posts_returns_every_fetched_post(client): def test_get_blog_posts_returns_local_backup_on_failure(client): - """Endpoint returns local backup (non-empty list) when fetcher fails.""" + """Endpoint returns the local backup, with empty tags, when the fetcher raises.""" with patch( "litellm.proxy.public_endpoints.public_endpoints.get_blog_posts", side_effect=Exception("fetch failed"), ): response = client.get("/public/litellm_blog_posts") - # Should not 500 — returns local backup assert response.status_code == 200 - assert "posts" in response.json() - assert len(response.json()["posts"]) > 0 + backup = [post.model_dump(mode="json") for post in GetBlogPosts.load_local_blog_posts()] + assert response.json()["posts"] == backup + assert all(post["tags"] == [] for post in response.json()["posts"]) diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py index 4f1cf2d8c28..129dbaf9d5e 100644 --- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py @@ -1,9 +1,6 @@ import json -import os import re -from collections.abc import Iterator from datetime import datetime, timezone -from importlib.resources import files from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -19,7 +16,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, WhatsNewResponse +from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo from litellm.types.utils import LlmProviders @@ -78,7 +75,6 @@ def test_get_provider_create_fields(): def test_get_litellm_model_cost_map_catalog_only_excludes_runtime_registered_entries( monkeypatch: pytest.MonkeyPatch, ) -> None: - import litellm from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap monkeypatch.setattr(litellm, "model_cost", dict(litellm.model_cost)) @@ -1538,159 +1534,3 @@ async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypa 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_serves_bundled_list_without_fetching_when_no_url_is_configured( - 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) - - async def forbidden_fetch(url: str) -> WhatsNewResponse: - raise AssertionError(f"no URL is configured, so nothing may be fetched, got {url}") - - served = await get_whats_new(url=None, fetch=forbidden_fetch) - - assert served == _load_bundled_whats_new() - assert len(served.launches) > 0 - - -def test_whats_new_url_default_mirrors_the_env_var_so_no_remote_url_is_baked_in() -> None: - assert litellm.whats_new_url == os.environ.get("LITELLM_WHATS_NEW_URL") - - -@pytest.mark.asyncio -async def test_public_whats_new_route_serves_the_bundled_list_without_fetching_when_no_url_is_configured( - monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None -) -> None: - from litellm.proxy.public_endpoints import public_endpoints - from litellm.proxy.public_endpoints.public_endpoints import _load_bundled_whats_new, get_public_whats_new - - monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False) - - async def forbidden_fetch(url: str) -> bytes: - raise AssertionError(f"no URL is configured, so the route may not fetch, got {url}") - - monkeypatch.setattr(public_endpoints, "_fetch_remote_bytes", forbidden_fetch) - - served = await get_public_whats_new() - - assert served == _load_bundled_whats_new() - 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 5686f45efd3..499dd9bcf42 100644 --- a/tests/unit/test_get_blog_posts.py +++ b/tests/unit/test_get_blog_posts.py @@ -25,6 +25,8 @@ SAMPLE_RSS = """\ https://docs.litellm.ai/blog/test A test post. Wed, 01 Jan 2026 10:00:00 GMT + anthropic + day 0 support Second Post @@ -36,6 +38,8 @@ SAMPLE_RSS = """\ """ +CACHED_POST = BlogPost(title="Cached", description="D", date="2026-01-01", url="https://x.com") + @pytest.fixture(autouse=True) def reset_blog_posts_cache(): @@ -46,30 +50,32 @@ def reset_blog_posts_cache(): GetBlogPosts._last_fetch_time = 0.0 -def test_load_local_blog_posts_returns_list(): +def test_load_local_blog_posts_returns_posts_without_tags(): posts = GetBlogPosts.load_local_blog_posts() - assert isinstance(posts, list) assert len(posts) > 0 - first = posts[0] - assert "title" in first - assert "description" in first - assert "date" in first - assert "url" in first + assert all(isinstance(post, BlogPost) for post in posts) + assert posts[0].tags == () def test_parse_rss_to_posts(): posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS, max_posts=1) assert len(posts) == 1 - assert posts[0]["title"] == "Test Post" - assert posts[0]["url"] == "https://docs.litellm.ai/blog/test" - assert posts[0]["description"] == "A test post." - assert posts[0]["date"] == "2026-01-01" + assert posts[0].title == "Test Post" + assert posts[0].url == "https://docs.litellm.ai/blog/test" + assert posts[0].description == "A test post." + assert posts[0].date == "2026-01-01" + + +def test_parse_rss_to_posts_reads_categories_as_tags(): + posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS) + assert posts[0].tags == ("anthropic", "day 0 support") + assert posts[1].tags == () def test_parse_rss_to_posts_multiple(): posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS, max_posts=5) assert len(posts) == 2 - assert posts[1]["title"] == "Second Post" + assert posts[1].title == "Second Post" def test_parse_rss_to_posts_default_cap(): @@ -80,8 +86,16 @@ def test_parse_rss_to_posts_default_cap(): ) 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}" + assert posts[0].title == "Post 0" + assert posts[-1].title == f"Post {BLOG_POSTS_MAX - 1}" + + +def test_parse_rss_to_posts_skips_items_without_title_or_link_before_capping(): + items = "no title" + "".join( + f"Post {i}https://docs.litellm.ai/blog/{i}" for i in range(2) + ) + posts = GetBlogPosts.parse_rss_to_posts(f"{items}", max_posts=2) + assert [post.title for post in posts] == ["Post 0", "Post 1"] def test_parse_rss_to_posts_invalid_xml(): @@ -95,16 +109,11 @@ 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"}] - assert GetBlogPosts.validate_blog_posts(posts) is True + assert GetBlogPosts.validate_blog_posts((CACHED_POST,)) is True -def test_validate_blog_posts_empty_list(): - assert GetBlogPosts.validate_blog_posts([]) is False - - -def test_validate_blog_posts_not_list(): - assert GetBlogPosts.validate_blog_posts("not a list") is False +def test_validate_blog_posts_empty(): + assert GetBlogPosts.validate_blog_posts(()) is False def test_get_blog_posts_success(): @@ -120,7 +129,8 @@ def test_get_blog_posts_success(): posts = get_blog_posts(url=litellm.blog_posts_url) assert len(posts) == 2 - assert posts[0]["title"] == "Test Post" + assert posts[0].title == "Test Post" + assert posts[0].tags == ("anthropic", "day 0 support") def test_get_blog_posts_network_error_falls_back_to_local(): @@ -131,8 +141,7 @@ def test_get_blog_posts_network_error_falls_back_to_local(): ): posts = get_blog_posts(url=litellm.blog_posts_url) - assert isinstance(posts, list) - assert len(posts) > 0 + assert posts == GetBlogPosts.load_local_blog_posts() def test_get_blog_posts_invalid_xml_falls_back_to_local(): @@ -147,52 +156,25 @@ def test_get_blog_posts_invalid_xml_falls_back_to_local(): ): posts = get_blog_posts(url=litellm.blog_posts_url) - assert isinstance(posts, list) - assert len(posts) > 0 + assert posts == GetBlogPosts.load_local_blog_posts() def test_get_blog_posts_ttl_cache_not_refetched(): """Within TTL window, does not re-fetch.""" - cached = [ - { - "title": "Cached", - "description": "D", - "date": "2026-01-01", - "url": "https://x.com", - } - ] - GetBlogPosts._cached_posts = cached - GetBlogPosts._last_fetch_time = time.time() # just now + GetBlogPosts._cached_posts = (CACHED_POST,) + GetBlogPosts._last_fetch_time = time.time() - call_count = 0 - - def mock_get(*args, **kwargs): - nonlocal call_count - call_count += 1 - m = MagicMock() - m.text = SAMPLE_RSS - 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") as mock_get: posts = get_blog_posts(url=litellm.blog_posts_url) - assert call_count == 0 # cache hit, no fetch - assert len(posts) == 1 + mock_get.assert_not_called() + assert posts == (CACHED_POST,) def test_get_blog_posts_ttl_expired_refetches(): """After TTL window, re-fetches from remote.""" - cached = [ - { - "title": "Cached", - "description": "D", - "date": "2026-01-01", - "url": "https://x.com", - } - ] - GetBlogPosts._cached_posts = cached - GetBlogPosts._last_fetch_time = time.time() - 7200 # 2 hours ago + GetBlogPosts._cached_posts = (CACHED_POST,) + GetBlogPosts._last_fetch_time = time.time() - 7200 mock_response = MagicMock() mock_response.text = SAMPLE_RSS @@ -213,20 +195,9 @@ def test_get_blog_posts_local_env_var_skips_remote(monkeypatch): with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get") as mock_get: posts = get_blog_posts(url=litellm.blog_posts_url) mock_get.assert_not_called() - assert isinstance(posts, list) - assert len(posts) > 0 + assert posts == GetBlogPosts.load_local_blog_posts() -def test_blog_post_pydantic_model(): - post = BlogPost( - title="T", - description="D", - date="2026-01-01", - url="https://example.com", - ) - assert post.title == "T" - - -def test_blog_posts_response_pydantic_model(): - resp = BlogPostsResponse(posts=[BlogPost(title="T", description="D", date="2026-01-01", url="https://x.com")]) - assert len(resp.posts) == 1 +def test_blog_posts_response_serializes_tags_as_a_list(): + resp = BlogPostsResponse(posts=(CACHED_POST.model_copy(update={"tags": ("day 0 support",)}),)) + assert resp.model_dump(mode="json")["posts"][0]["tags"] == ["day 0 support"] 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 index 4e87cd06f7a..598fbf74efc 100644 --- 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 @@ -33,17 +33,15 @@ vi.mock("@/components/networking", async (importOriginal) => ({ 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, +const post = (title: string, date: string, tags: string[] = []) => ({ title, description: `${title} description`, - href: `https://docs.litellm.ai/blog/${title}`, - published_on, + date, + url: title === "Post one" ? "https://x/1" : `https://docs.litellm.ai/blog/${title}`, + tags, }); -const whatsNew = vi.hoisted(() => ({ launches: [] as unknown[] })); +const blogPosts = vi.hoisted(() => ({ posts: [] as unknown[] })); const requestUrl = (input: RequestInfo | URL): string => { if (typeof input === "string") return input; @@ -57,9 +55,8 @@ const fakeFetch = (input: RequestInfo | URL) => { if (requestUrl(input).endsWith("/sso/get/ui_settings")) { return Promise.resolve(Response.json({ PROXY_BASE_URL: "http://proxy.test" })); } - 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" } }), + new Response(JSON.stringify(blogPosts), { status: 200, headers: { "Content-Type": "application/json" } }), ); }; @@ -118,7 +115,7 @@ describe("HomePage", () => { 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")]; + blogPosts.posts = [post("Post one", "2026-10-09"), post("mid", "2026-10-06", ["day 0 support"])]; vi.stubGlobal("fetch", vi.fn(fakeFetch)); }); afterEach(() => { @@ -168,11 +165,13 @@ describe("HomePage", () => { 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"), + it("renders the three newest day 0 support blog posts as What's new, skipping untagged posts", async () => { + blogPosts.posts = [ + post("mid", "2026-10-06", ["openai", "day 0 support"]), + post("townhall", "2026-10-12", ["announcement"]), + post("new", "2026-10-11", ["day 0 support"]), + post("old", "2026-09-30", ["day 0 support"]), + post("oldest", "2026-09-01", ["day 0 support"]), ]; renderHome(); const section = await screen.findByTestId("home-whats-new"); @@ -187,10 +186,31 @@ describe("HomePage", () => { "https://docs.litellm.ai/blog/old", ]); expect(within(section).getByText("Oct 11")).toBeInTheDocument(); + expect(within(section).queryByText("townhall")).not.toBeInTheDocument(); }); - it("hides What's new when /public/whats_new has no launches", async () => { - whatsNew.launches = []; + it("renders card_title, card_summary and the provider logo, falling back to the title and first sentence", async () => { + blogPosts.posts = [ + post("Gemini 4 Argon API Pricing and Day 0 Support on LiteLLM", "2026-10-13", [ + "gemini", + "card_title:Gemini 4 Argon", + "card_summary:Gemini 4 Argon costs $2.00 per 1M input tokens.", + "day 0 support", + ]), + post("Day 0 Support: Grok 4.7", "2026-09-21", ["nano banana", "day 0 support"]), + ]; + renderHome(); + const section = await screen.findByTestId("home-whats-new"); + expect(await within(section).findByText("Gemini 4 Argon")).toBeInTheDocument(); + expect(within(section).getByText("Gemini 4 Argon costs $2.00 per 1M input tokens.")).toBeInTheDocument(); + expect(within(section).getByRole("img", { name: "gemini logo" })).toBeInTheDocument(); + expect(within(section).getByText("Day 0 Support: Grok 4.7")).toBeInTheDocument(); + expect(within(section).getByText("Day 0 Support: Grok 4.7 description")).toBeInTheDocument(); + expect(within(section).getAllByRole("img")).toHaveLength(1); + }); + + it("hides What's new when no blog post is tagged day 0 support", async () => { + blogPosts.posts = [post("Post one", "2026-10-09", ["announcement"])]; renderHome(); expect(await screen.findByRole("link", { name: /post one/i })).toBeInTheDocument(); expect(screen.queryByTestId("home-whats-new")).not.toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx index dcb0cd771b6..b0c63bb5dc0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowRight, Plus, Sparkles } from "lucide-react"; +import { ArrowRight, Plus, Rocket, Sparkles } from "lucide-react"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -12,17 +12,19 @@ import { LiteAdminHome } from "@/components/liteadmin/LiteAdmin"; 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 { useBlogPosts } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; +import { cardProvider, cardSummary, cardTitle, formatPublishedOn, latestLaunches } from "./homeContent"; +import { Logo } from "@/components/molecules/logo/Logo"; 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 blog = useBlogPosts(); + const launches = latestLaunches(blog.data?.posts ?? []); const { series, ranking } = useBreakdown(usage.results, LEADERBOARD, 8); return ( @@ -62,7 +64,7 @@ export default function HomePage() {
- {(whatsNew.isLoading || (whatsNew.data?.length ?? 0) > 0) && ( + {(blog.isLoading || launches.length > 0) && (

@@ -71,22 +73,22 @@ export default function HomePage() {

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

{item.description}

+
{cardTitle(post)}
+

{cardSummary(post)}

- {formatPublishedOn(item.published_on)} + {formatPublishedOn(post.date)}
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 index 7fad5767d7f..5757b26a6cf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.test.ts @@ -1,40 +1,75 @@ -import { Box, Sparkles } from "lucide-react"; import { describe, expect, it } from "vitest"; -import { latestLaunches, launchIcon, type WhatsNewLaunch } from "./homeContent"; +import type { BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; +import { DAY_ZERO_TAG, cardProvider, cardSummary, cardTitle, firstSentence, latestLaunches } from "./homeContent"; -const launch = (title: string, published_on: string): WhatsNewLaunch => ({ - icon: "box", +const post = (title: string, date: string, tags: string[] = [DAY_ZERO_TAG]): BlogPost => ({ title, description: "d", - href: `https://docs.litellm.ai/${title}`, - published_on, + date, + url: `https://docs.litellm.ai/blog/${title}`, + tags, }); 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 posts tagged day 0 support", () => { + const posts = [ + post("townhall", "2026-10-12", ["announcement"]), + post("argon", "2026-10-13"), + post("untagged", "2026-10-11", []), + ]; + expect(latestLaunches(posts).map((p) => p.title)).toEqual(["argon"]); + }); + + it("orders launches by date, newest first, without mutating the input", () => { + const posts = [post("old", "2026-09-01"), post("new", "2026-10-11"), post("mid", "2026-10-01")]; + expect(latestLaunches(posts).map((p) => p.title)).toEqual(["new", "mid", "old"]); + expect(posts.map((p) => p.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"), + const posts = [ + post("oldest", "2026-08-01"), + post("a", "2026-10-03"), + post("b", "2026-10-02"), + post("c", "2026-10-01"), ]; - expect(latestLaunches(launches).map((l) => l.title)).toEqual(["a", "b", "c"]); + expect(latestLaunches(posts).map((p) => p.title)).toEqual(["a", "b", "c"]); }); }); -describe("launchIcon", () => { - it("maps a known icon name to its lucide icon", () => { - expect(launchIcon("box")).toBe(Box); +describe("card fields", () => { + const argon = post("Gemini 4 Argon API Pricing and Day 0 Support on LiteLLM", "2026-10-13", [ + "gemini", + "gemini 4", + "card_title:Gemini 4 Argon", + "card_summary:Gemini 4 Argon costs $2.00 per 1M input tokens.", + DAY_ZERO_TAG, + ]); + const legacy = { + ...post("Day 0 Support: Grok 4.7", "2026-09-21", ["xai", DAY_ZERO_TAG]), + description: "Day 0 support for Grok 4.7 on LiteLLM, at the same price as Grok 4.6. Use it anywhere.", + }; + + it("reads card_title and card_summary from the tags", () => { + expect(cardTitle(argon)).toBe("Gemini 4 Argon"); + expect(cardSummary(argon)).toBe("Gemini 4 Argon costs $2.00 per 1M input tokens."); }); - 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); + it("falls back to the full title and the first sentence of the description", () => { + expect(cardTitle(legacy)).toBe("Day 0 Support: Grok 4.7"); + expect(cardSummary(legacy)).toBe("Day 0 support for Grok 4.7 on LiteLLM, at the same price as Grok 4.6."); + expect(firstSentence("No terminal punctuation here")).toBe("No terminal punctuation here"); + expect(firstSentence("Costs $0.10 per 1M tokens. Twice.")).toBe("Costs $0.10 per 1M tokens."); + }); + + it("treats an empty card tag as absent", () => { + const blank = post("Full title", "2026-10-01", ["card_title: ", DAY_ZERO_TAG]); + expect(cardTitle(blank)).toBe("Full title"); + }); + + it("picks the provider logo from the first tag that is a litellm provider", () => { + expect(cardProvider(argon)).toBe("gemini"); + expect(cardProvider(legacy)).toBe("xai"); + expect(cardProvider(post("x", "2026-10-01", ["nano banana", DAY_ZERO_TAG]))).toBeUndefined(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts index c3eb4b2c838..37549b48588 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts @@ -1,26 +1,37 @@ -import { Bot, Box, Gauge, Plug, Rocket, Scale, Shield, Sparkles, Zap, type LucideIcon } from "lucide-react"; -import type { components } from "@/lib/http/schema"; +import type { BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; +import { getProviderLogoAndName } from "@/components/provider_info_helpers"; -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 DAY_ZERO_TAG = "day 0 support"; 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); +const CARD_TITLE_TAG = "card_title:"; + +const CARD_SUMMARY_TAG = "card_summary:"; + +export const latestLaunches = (posts: readonly BlogPost[]): readonly BlogPost[] => + posts + .filter((post) => post.tags.includes(DAY_ZERO_TAG)) + .sort((a, b) => b.date.localeCompare(a.date)) + .slice(0, WHATS_NEW_MAX); + +const taggedValue = (tags: readonly string[], prefix: string): string | undefined => { + const value = tags + .find((tag) => tag.startsWith(prefix)) + ?.slice(prefix.length) + .trim(); + return value ? value : undefined; +}; + +export const firstSentence = (text: string): string => /^.*?[.!?](?=\s|$)/.exec(text)?.[0] ?? text; + +export const cardTitle = (post: BlogPost): string => taggedValue(post.tags, CARD_TITLE_TAG) ?? post.title; + +export const cardSummary = (post: BlogPost): string => + taggedValue(post.tags, CARD_SUMMARY_TAG) ?? firstSentence(post.description); + +export const cardProvider = (post: BlogPost): string | undefined => + post.tags.find((tag) => getProviderLogoAndName(tag).logo !== ""); 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/useWhatsNew.ts b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useWhatsNew.ts deleted file mode 100644 index c2171f3349a..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useWhatsNew.ts +++ /dev/null @@ -1,10 +0,0 @@ -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)/hooks/blogPosts/useBlogPosts.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts index 81d55e87650..dee3274966f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts @@ -6,6 +6,7 @@ export interface BlogPost { description: string; date: string; url: string; + tags: string[]; } export interface BlogPostsResponse { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index adf020ca96a..f598ad012db 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -13454,9 +13454,9 @@ export interface paths { }; /** * Get Litellm Blog Posts - * @description Public endpoint to get the latest LiteLLM blog posts. + * @description Public endpoint to get the latest LiteLLM blog posts, each with its RSS categories as ``tags``. * - * Fetches from GitHub with a 1-hour in-process cache. + * Fetches the docs RSS feed with a 1-hour in-process cache. * Falls back to the bundled local backup on any failure. */ get: operations["get_litellm_blog_posts_public_litellm_blog_posts_get"]; @@ -13661,31 +13661,6 @@ 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. - * - * Serves the list bundled with this release. Set ``LITELLM_WHATS_NEW_URL`` to fetch a remote list instead, - * resolved once per process on the first request and falling back to the bundled list on any failure, so an - * airgapped proxy serves the bundled list without retrying. ``LITELLM_LOCAL_WHATS_NEW=True`` forces the - * bundled list even when a URL is set. 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; @@ -27056,6 +27031,11 @@ export interface components { date: string; /** Description */ description: string; + /** + * Tags + * @default [] + */ + tags: string[]; /** Title */ title: string; /** Url */ @@ -49281,30 +49261,6 @@ 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"][]; - }; /** WorkerRegistryEntry */ WorkerRegistryEntry: { /** Name */ @@ -68725,26 +68681,6 @@ 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?: {