mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-14 04:09:09 +00:00
feat(ui): source Home What's new from day 0 support blog posts (#46260)
* feat(ui): source Home What's new from day 0 support blog posts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(ui): Home cards read card_title and card_summary from blog tags with provider logo Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
829bd6a8cc
commit
d7b58126a7
16 changed files with 280 additions and 564 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <item>.
|
||||
Extracts title, description, date (YYYY-MM-DD), url and the <category> tags from each <item>.
|
||||
"""
|
||||
root: Final = ET.fromstring(xml_text)
|
||||
channel: Final = root.find("channel")
|
||||
if channel is None:
|
||||
raise ValueError("RSS feed missing <channel> 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)
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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() == ()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ SAMPLE_RSS = """\
|
|||
<link>https://docs.litellm.ai/blog/test</link>
|
||||
<description>A test post.</description>
|
||||
<pubDate>Wed, 01 Jan 2026 10:00:00 GMT</pubDate>
|
||||
<category>anthropic</category>
|
||||
<category>day 0 support</category>
|
||||
</item>
|
||||
<item>
|
||||
<title>Second Post</title>
|
||||
|
|
@ -36,6 +38,8 @@ SAMPLE_RSS = """\
|
|||
</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"<rss><channel>{items}</channel></rss>")
|
||||
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 = "<item><description>no title</description></item>" + "".join(
|
||||
f"<item><title>Post {i}</title><link>https://docs.litellm.ai/blog/{i}</link></item>" for i in range(2)
|
||||
)
|
||||
posts = GetBlogPosts.parse_rss_to_posts(f"<rss><channel>{items}</channel></rss>", 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"]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(16rem,1fr)]">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{(whatsNew.isLoading || (whatsNew.data?.length ?? 0) > 0) && (
|
||||
{(blog.isLoading || launches.length > 0) && (
|
||||
<section aria-labelledby="home-whats-new" data-testid="home-whats-new" className="flex flex-col gap-3">
|
||||
<div>
|
||||
<h2 id="home-whats-new" className="flex items-center gap-2 text-lg font-semibold">
|
||||
|
|
@ -71,22 +73,22 @@ export default function HomePage() {
|
|||
</h2>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{whatsNew.isLoading &&
|
||||
{blog.isLoading &&
|
||||
[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-40 rounded-xl" data-testid="home-whats-new-loading" />
|
||||
))}
|
||||
{whatsNew.data?.map((item) => {
|
||||
const Icon = launchIcon(item.icon);
|
||||
{launches.map((post) => {
|
||||
const provider = cardProvider(post);
|
||||
return (
|
||||
<a key={item.title} href={item.href} target="_blank" rel="noopener noreferrer" className="group">
|
||||
<a key={post.url} href={post.url} target="_blank" rel="noopener noreferrer" className="group">
|
||||
<Card className="h-full gap-3 px-4 py-4 transition-colors hover:bg-muted/40">
|
||||
<div className="flex size-9 items-center justify-center rounded-md bg-muted">
|
||||
<Icon className="size-4" />
|
||||
{provider ? <Logo provider={provider} className="size-5" /> : <Rocket className="size-4" />}
|
||||
</div>
|
||||
<div className="text-base font-semibold leading-tight">{item.title}</div>
|
||||
<p className="m-0 line-clamp-3 text-sm text-muted-foreground">{item.description}</p>
|
||||
<div className="line-clamp-2 text-base font-semibold leading-tight">{cardTitle(post)}</div>
|
||||
<p className="m-0 line-clamp-3 text-sm text-muted-foreground">{cardSummary(post)}</p>
|
||||
<div className="mt-auto flex items-center justify-between pt-1 text-xs text-muted-foreground">
|
||||
<span>{formatPublishedOn(item.published_on)}</span>
|
||||
<span>{formatPublishedOn(post.date)}</span>
|
||||
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</div>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string, LucideIcon> = 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" });
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
);
|
||||
|
|
@ -6,6 +6,7 @@ export interface BlogPost {
|
|||
description: string;
|
||||
date: string;
|
||||
url: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
export interface BlogPostsResponse {
|
||||
|
|
|
|||
78
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
78
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue