mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-29 06:33:28 +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",
|
"LITELLM_AUTOROUTER_PRESETS_URL",
|
||||||
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/proxy/public_endpoints/autorouter_presets.json",
|
"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
|
suppress_debug_info: bool = False
|
||||||
dynamodb_table_name: Optional[str] = None
|
dynamodb_table_name: Optional[str] = None
|
||||||
s3_callback_params: Optional[Dict] = None
|
s3_callback_params: Optional[Dict] = None
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ Disable remote fetching entirely:
|
||||||
export LITELLM_LOCAL_BLOG_POSTS=True
|
export LITELLM_LOCAL_BLOG_POSTS=True
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
|
@ -17,23 +16,53 @@ from importlib.resources import files
|
||||||
from typing import Final
|
from typing import Final
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from pydantic import ConfigDict
|
||||||
|
|
||||||
from litellm import verbose_logger
|
from litellm import verbose_logger
|
||||||
from litellm.types.llms.base import LiteLLMBaseModel
|
from litellm.types.llms.base import LiteLLMBaseModel
|
||||||
|
|
||||||
BLOG_POSTS_TTL_SECONDS: Final[int] = 3600 # 1 hour
|
BLOG_POSTS_TTL_SECONDS: Final[int] = 3600 # 1 hour
|
||||||
BLOG_POSTS_MAX: Final[int] = 6
|
BLOG_POSTS_MAX: Final[int] = 50
|
||||||
|
|
||||||
|
|
||||||
class BlogPost(LiteLLMBaseModel):
|
class BlogPost(LiteLLMBaseModel):
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
title: str
|
title: str
|
||||||
description: str
|
description: str
|
||||||
date: str
|
date: str
|
||||||
url: str
|
url: str
|
||||||
|
tags: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
class BlogPostsResponse(LiteLLMBaseModel):
|
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:
|
class GetBlogPosts:
|
||||||
|
|
@ -41,19 +70,20 @@ class GetBlogPosts:
|
||||||
Fetches, validates, and caches LiteLLM blog posts.
|
Fetches, validates, and caches LiteLLM blog posts.
|
||||||
|
|
||||||
- Fetches RSS feed from docs site with a 5-second timeout
|
- 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)
|
- Caches the result in-process for BLOG_POSTS_TTL_SECONDS (1 hour)
|
||||||
- Falls back to the bundled local backup on any failure
|
- 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
|
_last_fetch_time: float = 0.0
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load_local_blog_posts() -> list[dict[str, str]]:
|
def load_local_blog_posts() -> tuple[BlogPost, ...]:
|
||||||
"""Load the bundled local backup blog posts."""
|
"""Load the bundled local backup blog posts."""
|
||||||
content: Final = json.loads(files("litellm").joinpath("blog_posts.json").read_text(encoding="utf-8"))
|
return BlogPostsResponse.model_validate_json(
|
||||||
return content.get("posts", [])
|
files("litellm").joinpath("blog_posts.json").read_text(encoding="utf-8")
|
||||||
|
).posts
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def fetch_rss_feed(url: str, timeout: int = 5) -> str:
|
def fetch_rss_feed(url: str, timeout: int = 5) -> str:
|
||||||
|
|
@ -67,54 +97,24 @@ class GetBlogPosts:
|
||||||
return response.text
|
return response.text
|
||||||
|
|
||||||
@staticmethod
|
@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)
|
root: Final = ET.fromstring(xml_text)
|
||||||
channel: Final = root.find("channel")
|
channel: Final = root.find("channel")
|
||||||
if channel is None:
|
if channel is None:
|
||||||
raise ValueError("RSS feed missing <channel> element")
|
raise ValueError("RSS feed missing <channel> element")
|
||||||
|
|
||||||
posts: Final[list[dict[str, str]]] = []
|
parsed: Final = (_parse_item(item) for item in channel.findall("item"))
|
||||||
for item in channel.findall("item"):
|
return tuple(post for post in parsed if post is not None)[:max_posts]
|
||||||
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
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def validate_blog_posts(posts: list[dict[str, str]]) -> bool:
|
def validate_blog_posts(posts: tuple[BlogPost, ...]) -> bool:
|
||||||
"""Return True if posts is a non-empty list."""
|
"""Return True if there is at least one post."""
|
||||||
if not isinstance(posts, list) or len(posts) == 0:
|
if len(posts) == 0:
|
||||||
verbose_logger.warning(
|
verbose_logger.warning(
|
||||||
"LiteLLM: Parsed RSS feed has no valid posts. Falling back to local backup.",
|
"LiteLLM: Parsed RSS feed has no valid posts. Falling back to local backup.",
|
||||||
)
|
)
|
||||||
|
|
@ -122,9 +122,9 @@ class GetBlogPosts:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@classmethod
|
@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.
|
Uses the in-process cache if within BLOG_POSTS_TTL_SECONDS.
|
||||||
Fetches from ``url`` otherwise, falling back to local backup on failure.
|
Fetches from ``url`` otherwise, falling back to local backup on failure.
|
||||||
|
|
@ -156,6 +156,6 @@ class GetBlogPosts:
|
||||||
return posts
|
return posts
|
||||||
|
|
||||||
|
|
||||||
def get_blog_posts(url: str) -> list[dict[str, str]]:
|
def get_blog_posts(url: str) -> tuple[BlogPost, ...]:
|
||||||
"""Public entry point — returns the blog posts list."""
|
"""Public entry point, returns the blog posts."""
|
||||||
return GetBlogPosts.get_blog_posts(url=url)
|
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 importlib.resources import files
|
||||||
from typing import TYPE_CHECKING, Final, Protocol
|
from typing import TYPE_CHECKING, Final, Protocol
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import TypeAdapter, ValidationError
|
from pydantic import TypeAdapter
|
||||||
from typing_extensions import ReadOnly, TypedDict
|
from typing_extensions import ReadOnly, TypedDict
|
||||||
|
|
||||||
import litellm
|
import litellm
|
||||||
from litellm._logging import verbose_logger
|
from litellm._logging import verbose_logger
|
||||||
from litellm.litellm_core_utils.get_blog_posts import (
|
from litellm.litellm_core_utils.get_blog_posts import (
|
||||||
BlogPost,
|
|
||||||
BlogPostsResponse,
|
BlogPostsResponse,
|
||||||
GetBlogPosts,
|
GetBlogPosts,
|
||||||
get_blog_posts,
|
get_blog_posts,
|
||||||
|
|
@ -38,7 +36,6 @@ from litellm.types.proxy.public_endpoints.public_endpoints import (
|
||||||
ProviderCreateInfo,
|
ProviderCreateInfo,
|
||||||
PublicModelHubInfo,
|
PublicModelHubInfo,
|
||||||
SupportedEndpointsResponse,
|
SupportedEndpointsResponse,
|
||||||
WhatsNewResponse,
|
|
||||||
)
|
)
|
||||||
from litellm.types.utils import LlmProviders
|
from litellm.types.utils import LlmProviders
|
||||||
|
|
||||||
|
|
@ -479,21 +476,18 @@ async def get_litellm_model_cost_map(catalog_only: bool = False):
|
||||||
tags=["public"],
|
tags=["public"],
|
||||||
response_model=BlogPostsResponse,
|
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.
|
Falls back to the bundled local backup on any failure.
|
||||||
"""
|
"""
|
||||||
try:
|
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:
|
except Exception as e:
|
||||||
verbose_logger.warning("LiteLLM: get_litellm_blog_posts endpoint fallback triggered: %s", str(e))
|
verbose_logger.warning("LiteLLM: get_litellm_blog_posts endpoint fallback triggered: %s", str(e))
|
||||||
posts_data = GetBlogPosts.load_local_blog_posts()
|
return BlogPostsResponse(posts=GetBlogPosts.load_local_blog_posts())
|
||||||
|
|
||||||
posts: Final = [BlogPost(**p) for p in posts_data]
|
|
||||||
return BlogPostsResponse(posts=posts)
|
|
||||||
|
|
||||||
|
|
||||||
_AUTOROUTER_PRESETS_ADAPTER: Final = TypeAdapter(dict[str, AutoRouterPresetRecord])
|
_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)
|
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(
|
@router.get(
|
||||||
"/public/endpoints",
|
"/public/endpoints",
|
||||||
tags=["public"],
|
tags=["public"],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
from datetime import date
|
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
|
|
@ -114,24 +113,6 @@ class AutoRouterPresetRecord(LiteLLMBaseModel):
|
||||||
complexity_router_config: AutoRouterPresetConfig
|
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):
|
class ComplexityScorerDefaults(LiteLLMBaseModel):
|
||||||
"""The complexity router's shipped heuristic scorer defaults.
|
"""The complexity router's shipped heuristic scorer defaults.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,6 @@ LOCAL_CATALOGS: Final = MappingProxyType(
|
||||||
"LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS",
|
"LITELLM_LOCAL_ANTHROPIC_BETA_HEADERS",
|
||||||
"LITELLM_LOCAL_AUTOROUTER_PRESETS",
|
"LITELLM_LOCAL_AUTOROUTER_PRESETS",
|
||||||
"LITELLM_LOCAL_POLICY_TEMPLATES",
|
"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.proxy.key == master_key
|
||||||
assert overridden.model_id
|
assert overridden.model_id
|
||||||
assert overridden.proxy.request("GET", "/model/info").status_code == 200
|
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() == ()
|
assert overridden.egress() == ()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,17 @@ from unittest.mock import patch
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
SAMPLE_POSTS = [
|
from litellm.litellm_core_utils.get_blog_posts import BlogPost, GetBlogPosts
|
||||||
{
|
|
||||||
"title": "Test Post",
|
SAMPLE_POSTS = (
|
||||||
"description": "A test post.",
|
BlogPost(
|
||||||
"date": "2026-01-01",
|
title="Test Post",
|
||||||
"url": "https://www.litellm.ai/blog/test",
|
description="A test post.",
|
||||||
}
|
date="2026-01-01",
|
||||||
]
|
url="https://www.litellm.ai/blog/test",
|
||||||
|
tags=("anthropic", "day 0 support"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
@ -35,27 +38,25 @@ def test_get_blog_posts_returns_response_shape(client):
|
||||||
response = client.get("/public/litellm_blog_posts")
|
response = client.get("/public/litellm_blog_posts")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
assert response.json() == {
|
||||||
assert "posts" in data
|
"posts": [
|
||||||
assert len(data["posts"]) == 1
|
{
|
||||||
post = data["posts"][0]
|
"title": "Test Post",
|
||||||
assert post["title"] == "Test Post"
|
"description": "A test post.",
|
||||||
assert post["description"] == "A test post."
|
"date": "2026-01-01",
|
||||||
assert post["date"] == "2026-01-01"
|
"url": "https://www.litellm.ai/blog/test",
|
||||||
assert post["url"] == "https://www.litellm.ai/blog/test"
|
"tags": ["anthropic", "day 0 support"],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_get_blog_posts_returns_every_fetched_post(client):
|
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."""
|
"""The parser owns the cap (BLOG_POSTS_MAX), so the endpoint passes every fetched post through."""
|
||||||
many_posts = [
|
many_posts = tuple(
|
||||||
{
|
BlogPost(title=f"Post {i}", description="desc", date="2026-01-01", url=f"https://www.litellm.ai/blog/{i}")
|
||||||
"title": f"Post {i}",
|
|
||||||
"description": "desc",
|
|
||||||
"date": "2026-01-01",
|
|
||||||
"url": f"https://www.litellm.ai/blog/{i}",
|
|
||||||
}
|
|
||||||
for i in range(10)
|
for i in range(10)
|
||||||
]
|
)
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"litellm.proxy.public_endpoints.public_endpoints.get_blog_posts",
|
"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):
|
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(
|
with patch(
|
||||||
"litellm.proxy.public_endpoints.public_endpoints.get_blog_posts",
|
"litellm.proxy.public_endpoints.public_endpoints.get_blog_posts",
|
||||||
side_effect=Exception("fetch failed"),
|
side_effect=Exception("fetch failed"),
|
||||||
):
|
):
|
||||||
response = client.get("/public/litellm_blog_posts")
|
response = client.get("/public/litellm_blog_posts")
|
||||||
|
|
||||||
# Should not 500 — returns local backup
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert "posts" in response.json()
|
backup = [post.model_dump(mode="json") for post in GetBlogPosts.load_local_blog_posts()]
|
||||||
assert len(response.json()["posts"]) > 0
|
assert response.json()["posts"] == backup
|
||||||
|
assert all(post["tags"] == [] for post in response.json()["posts"])
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import Iterator
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from importlib.resources import files
|
|
||||||
from typing import Final
|
from typing import Final
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
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 (
|
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||||
ModelGroupInfoProxy,
|
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
|
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(
|
def test_get_litellm_model_cost_map_catalog_only_excludes_runtime_registered_entries(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
import litellm
|
|
||||||
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
|
from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap
|
||||||
|
|
||||||
monkeypatch.setattr(litellm, "model_cost", dict(litellm.model_cost))
|
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"{}"
|
response.content = b"{}"
|
||||||
with pytest.raises(ValueError, match="empty"):
|
with pytest.raises(ValueError, match="empty"):
|
||||||
await _fetch_remote_autorouter_presets("https://example.test/presets.json")
|
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>
|
<link>https://docs.litellm.ai/blog/test</link>
|
||||||
<description>A test post.</description>
|
<description>A test post.</description>
|
||||||
<pubDate>Wed, 01 Jan 2026 10:00:00 GMT</pubDate>
|
<pubDate>Wed, 01 Jan 2026 10:00:00 GMT</pubDate>
|
||||||
|
<category>anthropic</category>
|
||||||
|
<category>day 0 support</category>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<title>Second Post</title>
|
<title>Second Post</title>
|
||||||
|
|
@ -36,6 +38,8 @@ SAMPLE_RSS = """\
|
||||||
</rss>
|
</rss>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
CACHED_POST = BlogPost(title="Cached", description="D", date="2026-01-01", url="https://x.com")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_blog_posts_cache():
|
def reset_blog_posts_cache():
|
||||||
|
|
@ -46,30 +50,32 @@ def reset_blog_posts_cache():
|
||||||
GetBlogPosts._last_fetch_time = 0.0
|
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()
|
posts = GetBlogPosts.load_local_blog_posts()
|
||||||
assert isinstance(posts, list)
|
|
||||||
assert len(posts) > 0
|
assert len(posts) > 0
|
||||||
first = posts[0]
|
assert all(isinstance(post, BlogPost) for post in posts)
|
||||||
assert "title" in first
|
assert posts[0].tags == ()
|
||||||
assert "description" in first
|
|
||||||
assert "date" in first
|
|
||||||
assert "url" in first
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_rss_to_posts():
|
def test_parse_rss_to_posts():
|
||||||
posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS, max_posts=1)
|
posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS, max_posts=1)
|
||||||
assert len(posts) == 1
|
assert len(posts) == 1
|
||||||
assert posts[0]["title"] == "Test Post"
|
assert posts[0].title == "Test Post"
|
||||||
assert posts[0]["url"] == "https://docs.litellm.ai/blog/test"
|
assert posts[0].url == "https://docs.litellm.ai/blog/test"
|
||||||
assert posts[0]["description"] == "A test post."
|
assert posts[0].description == "A test post."
|
||||||
assert posts[0]["date"] == "2026-01-01"
|
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():
|
def test_parse_rss_to_posts_multiple():
|
||||||
posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS, max_posts=5)
|
posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS, max_posts=5)
|
||||||
assert len(posts) == 2
|
assert len(posts) == 2
|
||||||
assert posts[1]["title"] == "Second Post"
|
assert posts[1].title == "Second Post"
|
||||||
|
|
||||||
|
|
||||||
def test_parse_rss_to_posts_default_cap():
|
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>")
|
posts = GetBlogPosts.parse_rss_to_posts(f"<rss><channel>{items}</channel></rss>")
|
||||||
assert len(posts) == BLOG_POSTS_MAX
|
assert len(posts) == BLOG_POSTS_MAX
|
||||||
assert posts[0]["title"] == "Post 0"
|
assert posts[0].title == "Post 0"
|
||||||
assert posts[-1]["title"] == f"Post {BLOG_POSTS_MAX - 1}"
|
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():
|
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():
|
def test_validate_blog_posts_valid():
|
||||||
posts = [{"title": "T", "description": "D", "date": "2026-01-01", "url": "https://x.com"}]
|
assert GetBlogPosts.validate_blog_posts((CACHED_POST,)) is True
|
||||||
assert GetBlogPosts.validate_blog_posts(posts) is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_blog_posts_empty_list():
|
def test_validate_blog_posts_empty():
|
||||||
assert GetBlogPosts.validate_blog_posts([]) is False
|
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_get_blog_posts_success():
|
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)
|
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||||
|
|
||||||
assert len(posts) == 2
|
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():
|
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)
|
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||||
|
|
||||||
assert isinstance(posts, list)
|
assert posts == GetBlogPosts.load_local_blog_posts()
|
||||||
assert len(posts) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_blog_posts_invalid_xml_falls_back_to_local():
|
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)
|
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||||
|
|
||||||
assert isinstance(posts, list)
|
assert posts == GetBlogPosts.load_local_blog_posts()
|
||||||
assert len(posts) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_blog_posts_ttl_cache_not_refetched():
|
def test_get_blog_posts_ttl_cache_not_refetched():
|
||||||
"""Within TTL window, does not re-fetch."""
|
"""Within TTL window, does not re-fetch."""
|
||||||
cached = [
|
GetBlogPosts._cached_posts = (CACHED_POST,)
|
||||||
{
|
GetBlogPosts._last_fetch_time = time.time()
|
||||||
"title": "Cached",
|
|
||||||
"description": "D",
|
|
||||||
"date": "2026-01-01",
|
|
||||||
"url": "https://x.com",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
GetBlogPosts._cached_posts = cached
|
|
||||||
GetBlogPosts._last_fetch_time = time.time() # just now
|
|
||||||
|
|
||||||
call_count = 0
|
with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get") as mock_get:
|
||||||
|
|
||||||
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):
|
|
||||||
posts = get_blog_posts(url=litellm.blog_posts_url)
|
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||||
|
|
||||||
assert call_count == 0 # cache hit, no fetch
|
mock_get.assert_not_called()
|
||||||
assert len(posts) == 1
|
assert posts == (CACHED_POST,)
|
||||||
|
|
||||||
|
|
||||||
def test_get_blog_posts_ttl_expired_refetches():
|
def test_get_blog_posts_ttl_expired_refetches():
|
||||||
"""After TTL window, re-fetches from remote."""
|
"""After TTL window, re-fetches from remote."""
|
||||||
cached = [
|
GetBlogPosts._cached_posts = (CACHED_POST,)
|
||||||
{
|
GetBlogPosts._last_fetch_time = time.time() - 7200
|
||||||
"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
|
|
||||||
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
mock_response.text = SAMPLE_RSS
|
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:
|
with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get") as mock_get:
|
||||||
posts = get_blog_posts(url=litellm.blog_posts_url)
|
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||||
mock_get.assert_not_called()
|
mock_get.assert_not_called()
|
||||||
assert isinstance(posts, list)
|
assert posts == GetBlogPosts.load_local_blog_posts()
|
||||||
assert len(posts) > 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_blog_post_pydantic_model():
|
def test_blog_posts_response_serializes_tags_as_a_list():
|
||||||
post = BlogPost(
|
resp = BlogPostsResponse(posts=(CACHED_POST.model_copy(update={"tags": ("day 0 support",)}),))
|
||||||
title="T",
|
assert resp.model_dump(mode="json")["posts"][0]["tags"] == ["day 0 support"]
|
||||||
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
|
|
||||||
|
|
|
||||||
|
|
@ -33,17 +33,15 @@ vi.mock("@/components/networking", async (importOriginal) => ({
|
||||||
getProxyBaseUrl: () => "http://proxy.test",
|
getProxyBaseUrl: () => "http://proxy.test",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const blogPosts = { posts: [{ title: "Post one", description: "d", date: "2026-10-09", url: "https://x/1" }] };
|
const post = (title: string, date: string, tags: string[] = []) => ({
|
||||||
|
|
||||||
const launch = (title: string, icon: string, published_on: string) => ({
|
|
||||||
icon,
|
|
||||||
title,
|
title,
|
||||||
description: `${title} description`,
|
description: `${title} description`,
|
||||||
href: `https://docs.litellm.ai/blog/${title}`,
|
date,
|
||||||
published_on,
|
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 => {
|
const requestUrl = (input: RequestInfo | URL): string => {
|
||||||
if (typeof input === "string") return input;
|
if (typeof input === "string") return input;
|
||||||
|
|
@ -57,9 +55,8 @@ const fakeFetch = (input: RequestInfo | URL) => {
|
||||||
if (requestUrl(input).endsWith("/sso/get/ui_settings")) {
|
if (requestUrl(input).endsWith("/sso/get/ui_settings")) {
|
||||||
return Promise.resolve(Response.json({ PROXY_BASE_URL: "http://proxy.test" }));
|
return Promise.resolve(Response.json({ PROXY_BASE_URL: "http://proxy.test" }));
|
||||||
}
|
}
|
||||||
const body = requestUrl(input).endsWith("/public/whats_new") ? whatsNew : blogPosts;
|
|
||||||
return Promise.resolve(
|
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.aggregated.mockReset().mockResolvedValue(activity);
|
||||||
network.gateway.mockReset().mockResolvedValue({ total_successful_requests: 0, total_failed_requests: 0 });
|
network.gateway.mockReset().mockResolvedValue({ total_successful_requests: 0, total_failed_requests: 0 });
|
||||||
network.userInfo.mockReset().mockResolvedValue({ user_info: { max_budget: null } });
|
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));
|
vi.stubGlobal("fetch", vi.fn(fakeFetch));
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -168,11 +165,13 @@ describe("HomePage", () => {
|
||||||
expect(network.aggregated).toHaveBeenCalledTimes(2);
|
expect(network.aggregated).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the launches served by /public/whats_new newest first, whatever order the JSON lists them in", async () => {
|
it("renders the three newest day 0 support blog posts as What's new, skipping untagged posts", async () => {
|
||||||
whatsNew.launches = [
|
blogPosts.posts = [
|
||||||
launch("mid", "box", "2026-10-06"),
|
post("mid", "2026-10-06", ["openai", "day 0 support"]),
|
||||||
launch("new", "an-icon-from-a-newer-list", "2026-10-11"),
|
post("townhall", "2026-10-12", ["announcement"]),
|
||||||
launch("old", "scale", "2026-09-30"),
|
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();
|
renderHome();
|
||||||
const section = await screen.findByTestId("home-whats-new");
|
const section = await screen.findByTestId("home-whats-new");
|
||||||
|
|
@ -187,10 +186,31 @@ describe("HomePage", () => {
|
||||||
"https://docs.litellm.ai/blog/old",
|
"https://docs.litellm.ai/blog/old",
|
||||||
]);
|
]);
|
||||||
expect(within(section).getByText("Oct 11")).toBeInTheDocument();
|
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 () => {
|
it("renders card_title, card_summary and the provider logo, falling back to the title and first sentence", async () => {
|
||||||
whatsNew.launches = [];
|
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();
|
renderHome();
|
||||||
expect(await screen.findByRole("link", { name: /post one/i })).toBeInTheDocument();
|
expect(await screen.findByRole("link", { name: /post one/i })).toBeInTheDocument();
|
||||||
expect(screen.queryByTestId("home-whats-new")).not.toBeInTheDocument();
|
expect(screen.queryByTestId("home-whats-new")).not.toBeInTheDocument();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"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 useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
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 { Leaderboard, useBreakdown } from "@/app/(dashboard)/usage/_components/components/overview/BreakdownChart";
|
||||||
import { Panel } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
|
import { Panel } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
|
||||||
import UsageStatStrip from "@/app/(dashboard)/usage/_components/components/overview/UsageStatStrip";
|
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 UpdatesFeed from "./UpdatesFeed";
|
||||||
import { HOME_USAGE_DAYS, useHomeUsage } from "./useHomeUsage";
|
import { HOME_USAGE_DAYS, useHomeUsage } from "./useHomeUsage";
|
||||||
import { useWhatsNew } from "./useWhatsNew";
|
|
||||||
|
|
||||||
const LEADERBOARD = { metric: "tokens", dimension: "model_groups" } as const;
|
const LEADERBOARD = { metric: "tokens", dimension: "model_groups" } as const;
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { isViewOnly } = useAuthorized();
|
const { isViewOnly } = useAuthorized();
|
||||||
const usage = useHomeUsage();
|
const usage = useHomeUsage();
|
||||||
const whatsNew = useWhatsNew();
|
const blog = useBlogPosts();
|
||||||
|
const launches = latestLaunches(blog.data?.posts ?? []);
|
||||||
const { series, ranking } = useBreakdown(usage.results, LEADERBOARD, 8);
|
const { series, ranking } = useBreakdown(usage.results, LEADERBOARD, 8);
|
||||||
|
|
||||||
return (
|
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="grid gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(16rem,1fr)]">
|
||||||
<div className="flex min-w-0 flex-col gap-6">
|
<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">
|
<section aria-labelledby="home-whats-new" data-testid="home-whats-new" className="flex flex-col gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h2 id="home-whats-new" className="flex items-center gap-2 text-lg font-semibold">
|
<h2 id="home-whats-new" className="flex items-center gap-2 text-lg font-semibold">
|
||||||
|
|
@ -71,22 +73,22 @@ export default function HomePage() {
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
{whatsNew.isLoading &&
|
{blog.isLoading &&
|
||||||
[0, 1, 2].map((i) => (
|
[0, 1, 2].map((i) => (
|
||||||
<Skeleton key={i} className="h-40 rounded-xl" data-testid="home-whats-new-loading" />
|
<Skeleton key={i} className="h-40 rounded-xl" data-testid="home-whats-new-loading" />
|
||||||
))}
|
))}
|
||||||
{whatsNew.data?.map((item) => {
|
{launches.map((post) => {
|
||||||
const Icon = launchIcon(item.icon);
|
const provider = cardProvider(post);
|
||||||
return (
|
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">
|
<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">
|
<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>
|
||||||
<div className="text-base font-semibold leading-tight">{item.title}</div>
|
<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">{item.description}</p>
|
<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">
|
<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" />
|
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,75 @@
|
||||||
import { Box, Sparkles } from "lucide-react";
|
|
||||||
import { describe, expect, it } from "vitest";
|
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 => ({
|
const post = (title: string, date: string, tags: string[] = [DAY_ZERO_TAG]): BlogPost => ({
|
||||||
icon: "box",
|
|
||||||
title,
|
title,
|
||||||
description: "d",
|
description: "d",
|
||||||
href: `https://docs.litellm.ai/${title}`,
|
date,
|
||||||
published_on,
|
url: `https://docs.litellm.ai/blog/${title}`,
|
||||||
|
tags,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("latestLaunches", () => {
|
describe("latestLaunches", () => {
|
||||||
it("orders launches by published_on, newest first, without mutating the input", () => {
|
it("keeps only posts tagged day 0 support", () => {
|
||||||
const launches = [launch("old", "2026-09-01"), launch("new", "2026-10-11"), launch("mid", "2026-10-01")];
|
const posts = [
|
||||||
expect(latestLaunches(launches).map((l) => l.title)).toEqual(["new", "mid", "old"]);
|
post("townhall", "2026-10-12", ["announcement"]),
|
||||||
expect(launches.map((l) => l.title)).toEqual(["old", "new", "mid"]);
|
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", () => {
|
it("keeps only the three newest so the cards fill one row", () => {
|
||||||
const launches = [
|
const posts = [
|
||||||
launch("oldest", "2026-08-01"),
|
post("oldest", "2026-08-01"),
|
||||||
launch("a", "2026-10-03"),
|
post("a", "2026-10-03"),
|
||||||
launch("b", "2026-10-02"),
|
post("b", "2026-10-02"),
|
||||||
launch("c", "2026-10-01"),
|
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", () => {
|
describe("card fields", () => {
|
||||||
it("maps a known icon name to its lucide icon", () => {
|
const argon = post("Gemini 4 Argon API Pricing and Day 0 Support on LiteLLM", "2026-10-13", [
|
||||||
expect(launchIcon("box")).toBe(Box);
|
"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", () => {
|
it("falls back to the full title and the first sentence of the description", () => {
|
||||||
expect(launchIcon("hologram")).toBe(Sparkles);
|
expect(cardTitle(legacy)).toBe("Day 0 Support: Grok 4.7");
|
||||||
expect(launchIcon("constructor")).toBe(Sparkles);
|
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 { BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
|
||||||
import type { components } from "@/lib/http/schema";
|
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
|
||||||
|
|
||||||
export type WhatsNewLaunch = components["schemas"]["WhatsNewLaunch"];
|
export const DAY_ZERO_TAG = "day 0 support";
|
||||||
|
|
||||||
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 WHATS_NEW_MAX = 3;
|
export const WHATS_NEW_MAX = 3;
|
||||||
|
|
||||||
export const latestLaunches = (launches: readonly WhatsNewLaunch[]): readonly WhatsNewLaunch[] =>
|
const CARD_TITLE_TAG = "card_title:";
|
||||||
[...launches].sort((a, b) => b.published_on.localeCompare(a.published_on)).slice(0, WHATS_NEW_MAX);
|
|
||||||
|
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 =>
|
export const formatPublishedOn = (isoDate: string): string =>
|
||||||
new Date(`${isoDate}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
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;
|
description: string;
|
||||||
date: string;
|
date: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BlogPostsResponse {
|
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
|
* 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.
|
* Falls back to the bundled local backup on any failure.
|
||||||
*/
|
*/
|
||||||
get: operations["get_litellm_blog_posts_public_litellm_blog_posts_get"];
|
get: operations["get_litellm_blog_posts_public_litellm_blog_posts_get"];
|
||||||
|
|
@ -13661,31 +13661,6 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/queue/chat/completions": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -27056,6 +27031,11 @@ export interface components {
|
||||||
date: string;
|
date: string;
|
||||||
/** Description */
|
/** Description */
|
||||||
description: string;
|
description: string;
|
||||||
|
/**
|
||||||
|
* Tags
|
||||||
|
* @default []
|
||||||
|
*/
|
||||||
|
tags: string[];
|
||||||
/** Title */
|
/** Title */
|
||||||
title: string;
|
title: string;
|
||||||
/** Url */
|
/** Url */
|
||||||
|
|
@ -49281,30 +49261,6 @@ export interface components {
|
||||||
type: "web_search" | "web_search_2025_08_26";
|
type: "web_search" | "web_search_2025_08_26";
|
||||||
user_location?: components["schemas"]["openai__types__responses__web_search_tool_param__UserLocation"] | null;
|
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 */
|
||||||
WorkerRegistryEntry: {
|
WorkerRegistryEntry: {
|
||||||
/** Name */
|
/** 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: {
|
async_queue_request_queue_chat_completions_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue