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:
Kerry Lu 2026-10-13 13:11:59 -07:00 • committed by GitHub
parent 829bd6a8cc
commit d7b58126a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 280 additions and 564 deletions

View file

@ -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

View file

@ -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)

View file

@ -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"],

View file

@ -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.

View file

@ -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",
)
}
)

View file

@ -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() == ()

View file

@ -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"])

View file

@ -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)

View file

@ -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"]

View file

@ -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();

View file

@ -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>

View file

@ -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();
});
});

View file

@ -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" });

View file

@ -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) },
);

View file

@ -6,6 +6,7 @@ export interface BlogPost {
description: string;
date: string;
url: string;
tags: string[];
}
export interface BlogPostsResponse {

View file

@ -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?: {