feat(ui): load Home page What's new launches from /public/whats_new at runtime (#45912)
Some checks are pending
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Waiting to run
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Waiting to run
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / router-unit-tests (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
unit / custom-logging (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / guardrails-tests (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / endpoints (push) Blocked by required conditions
unit / enterprise-package (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / rust-bridge (push) Waiting to run
unit / assert-shard-coverage (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / lens-python-310 (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / proxy-infra (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Waiting to run
unit / OpenAI 3.0.0 compatibility (push) Waiting to run
unit / OpenAI 3.25.0 compatibility (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
unit / mcp-elicitation (push) Blocked by required conditions
unit / coverage (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions

* feat(ui): load the Home page What's new launches from /public/whats_new at runtime

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(ui): show the three newest What's new launches

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* build: ship whats_new.json in the wheel

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: kerry <kerry@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-10 19:42:18 -07:00 • committed by GitHub
parent 4c532a5713
commit 55c89977d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 505 additions and 146 deletions

View file

@ -440,6 +440,10 @@ autorouter_presets_url: str = os.getenv(
"LITELLM_AUTOROUTER_PRESETS_URL",
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/proxy/public_endpoints/autorouter_presets.json",
)
whats_new_url: str = os.getenv(
"LITELLM_WHATS_NEW_URL",
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/proxy/public_endpoints/whats_new.json",
)
suppress_debug_info: bool = False
dynamodb_table_name: Optional[str] = None
s3_callback_params: Optional[Dict] = None

View file

@ -6,8 +6,9 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence
from importlib.resources import files
from typing import TYPE_CHECKING, Final, Protocol
import httpx
from fastapi import APIRouter, HTTPException, Request
from pydantic import TypeAdapter
from pydantic import TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
import litellm
@ -37,6 +38,7 @@ from litellm.types.proxy.public_endpoints.public_endpoints import (
ProviderCreateInfo,
PublicModelHubInfo,
SupportedEndpointsResponse,
WhatsNewResponse,
)
from litellm.types.utils import LlmProviders
@ -505,14 +507,18 @@ def _load_bundled_autorouter_presets() -> Mapping[str, AutoRouterPresetRecord]:
)
async def _fetch_remote_autorouter_presets(url: str) -> Mapping[str, AutoRouterPresetRecord]:
async def _fetch_remote_bytes(url: str) -> bytes:
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.llms.custom_http import httpxSpecialProvider
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.UI)
response: Final = await client.get(url, timeout=5.0)
response.raise_for_status()
presets: Final = _AUTOROUTER_PRESETS_ADAPTER.validate_python(response.json())
return response.content
async def _fetch_remote_autorouter_presets(url: str) -> Mapping[str, AutoRouterPresetRecord]:
presets: Final = _AUTOROUTER_PRESETS_ADAPTER.validate_json(await _fetch_remote_bytes(url))
if not presets:
raise ValueError("remote auto-router preset catalog is empty")
return presets
@ -575,6 +581,70 @@ async def get_public_autorouter_presets() -> Mapping[str, AutoRouterPresetRecord
return await get_autorouter_presets(url=litellm.autorouter_presets_url)
def _load_bundled_whats_new() -> WhatsNewResponse:
return WhatsNewResponse.model_validate_json(
files("litellm.proxy.public_endpoints").joinpath("whats_new.json").read_text(encoding="utf-8")
)
async def _fetch_remote_whats_new(url: str) -> WhatsNewResponse:
return WhatsNewResponse.model_validate_json(await _fetch_remote_bytes(url))
async def _resolve_whats_new(url: str, fetch: Callable[[str], Awaitable[WhatsNewResponse]]) -> WhatsNewResponse:
if os.getenv("LITELLM_LOCAL_WHATS_NEW", "").lower() == "true":
return _load_bundled_whats_new()
try:
return await fetch(url)
except (httpx.HTTPError, ValidationError, OSError) as e:
verbose_logger.warning(
"LiteLLM: failed to fetch What's new launches from %s: %s. Serving the bundled list for the life of this process.",
url,
str(e),
)
return _load_bundled_whats_new()
class _WhatsNewCache:
launches: WhatsNewResponse | None = None
lock: asyncio.Lock | None = None
async def get_whats_new(
url: str,
fetch: Callable[[str], Awaitable[WhatsNewResponse]] = _fetch_remote_whats_new,
) -> WhatsNewResponse:
cached: Final = _WhatsNewCache.launches
if cached is not None:
return cached
if _WhatsNewCache.lock is None:
_WhatsNewCache.lock = asyncio.Lock()
async with _WhatsNewCache.lock:
held: Final = _WhatsNewCache.launches
if held is not None:
return held
resolved: Final = await _resolve_whats_new(url=url, fetch=fetch)
_WhatsNewCache.launches = resolved
return resolved
@router.get(
"/public/whats_new",
tags=["public"],
response_model=WhatsNewResponse,
)
async def get_public_whats_new() -> WhatsNewResponse:
"""
Return the launches the dashboard Home page shows under What's new.
Resolved once per process: fetched from ``litellm.whats_new_url`` (override with ``LITELLM_WHATS_NEW_URL``)
on the first request, falling back to the list bundled with the package on any failure, so an airgapped
proxy serves the bundled list without retrying. Set ``LITELLM_LOCAL_WHATS_NEW=True`` to skip the fetch.
A restart picks up a newly published list.
"""
return await get_whats_new(url=litellm.whats_new_url)
@router.get(
"/public/endpoints",
tags=["public"],

View file

@ -0,0 +1,25 @@
{
"launches": [
{
"icon": "box",
"title": "Claude Haiku 5.5",
"description": "Anthropic's newest small model, with day 0 pricing on Anthropic, Bedrock and Vertex AI.",
"href": "https://docs.litellm.ai/blog/claude-haiku-5-5",
"published_on": "2026-10-07"
},
{
"icon": "scale",
"title": "Decision Models",
"description": "Call decision models at /v1/decisions and try them in the Decisions playground.",
"href": "https://docs.litellm.ai/docs/decisions",
"published_on": "2026-10-07"
},
{
"icon": "zap",
"title": "OpenAI Ultrafast",
"description": "service_tier: ultrafast on GPT-6 Astra and GPT-6.1 Sol, including /ultrafast in Codex.",
"href": "https://docs.litellm.ai/docs/providers/openai/ultrafast",
"published_on": "2026-10-06"
}
]
}

View file

@ -1,4 +1,5 @@
from collections.abc import Mapping, Sequence
from datetime import date
from typing import Any, Literal
from pydantic import ConfigDict
@ -113,6 +114,24 @@ class AutoRouterPresetRecord(LiteLLMBaseModel):
complexity_router_config: AutoRouterPresetConfig
class WhatsNewLaunch(LiteLLMBaseModel):
"""One launch card in the dashboard Home page's What's new section."""
model_config = ConfigDict(frozen=True)
icon: str
title: str
description: str
href: str
published_on: date
class WhatsNewResponse(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
launches: tuple[WhatsNewLaunch, ...]
class ComplexityScorerDefaults(LiteLLMBaseModel):
"""The complexity router's shipped heuristic scorer defaults.

View file

@ -321,6 +321,7 @@ include = [
"litellm/router_strategy/complexity_router/artifacts/*.json",
"litellm/router_strategy/complexity_router/fuse_presets.json",
"litellm/proxy/model_insights_tasks.json",
"litellm/proxy/public_endpoints/whats_new.json",
"litellm/proxy/common_utils/codex_base_instructions.md",
"litellm/proxy/common_utils/codex_bundled_models_0.159.3.json",
]

View file

@ -1,6 +1,8 @@
import json
import re
from datetime import datetime, timezone
from importlib.resources import files
from collections.abc import Iterator
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
@ -17,7 +19,7 @@ from litellm.router_strategy.complexity_router.fuse_presets import get_fuse_pres
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
ModelGroupInfoProxy,
)
from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo
from litellm.types.proxy.public_endpoints.public_endpoints import ProviderCreateInfo, WhatsNewResponse
from litellm.types.utils import LlmProviders
@ -67,13 +69,10 @@ def test_get_provider_create_fields():
assert isinstance(first_provider["credential_fields"], list)
has_detailed_fields = any(
provider.get("credential_fields")
and len(provider.get("credential_fields", [])) > 0
provider.get("credential_fields") and len(provider.get("credential_fields", [])) > 0
for provider in response_data
)
assert (
has_detailed_fields
), "Expected at least one provider to have detailed credential fields"
assert has_detailed_fields, "Expected at least one provider to have detailed credential fields"
def test_get_litellm_model_cost_map_catalog_only_excludes_runtime_registered_entries(
@ -124,10 +123,7 @@ def test_get_litellm_model_cost_map_returns_cost_map():
sample_model_data = payload[sample_model]
assert isinstance(sample_model_data, dict)
# Check for common cost fields that should be present
assert (
"input_cost_per_token" in sample_model_data
or "output_cost_per_token" in sample_model_data
)
assert "input_cost_per_token" in sample_model_data or "output_cost_per_token" in sample_model_data
def test_public_ai_hub_info_is_public_by_default(monkeypatch):
@ -218,9 +214,9 @@ def test_anthropic_provider_fields_support_byok():
"Anthropic api_key must be optional so admins can configure BYOK models "
"without entering a key. See BYOK tutorial."
)
assert fields_by_key["api_key"].get(
"tooltip"
), "Anthropic api_key must have a tooltip explaining the BYOK use case."
assert fields_by_key["api_key"].get("tooltip"), (
"Anthropic api_key must have a tooltip explaining the BYOK use case."
)
assert "api_base" in fields_by_key, (
"Anthropic provider form must expose api_base so cloud customers "
"can override the upstream URL without env var access."
@ -228,16 +224,14 @@ def test_anthropic_provider_fields_support_byok():
api_base_field = fields_by_key["api_base"]
assert api_base_field["required"] is False
assert api_base_field["field_type"] == "text"
assert api_base_field.get(
"tooltip"
), "api_base should have a tooltip explaining it is optional."
assert api_base_field.get("tooltip"), "api_base should have a tooltip explaining it is optional."
# UI forms render fields in credential_fields order; api_base should come first
# so an admin sees the URL override before the key field.
field_order = [f["key"] for f in anthropic["credential_fields"]]
assert field_order.index("api_base") < field_order.index(
"api_key"
), "api_base must appear before api_key in credential_fields (matches AI21 and ANTHROPIC_TEXT convention)."
assert field_order.index("api_base") < field_order.index("api_key"), (
"api_base must appear before api_key in credential_fields (matches AI21 and ANTHROPIC_TEXT convention)."
)
def test_bedrock_mantle_provider_fields():
@ -554,9 +548,7 @@ def test_google_ai_studio_provider_fields_expose_api_base():
assert response.status_code == 200
providers = response.json()
google_ai = next(
(p for p in providers if p["provider"] == "Google_AI_Studio"), None
)
google_ai = next((p for p in providers if p["provider"] == "Google_AI_Studio"), None)
assert google_ai is not None, "Google_AI_Studio provider entry not found"
assert google_ai["litellm_provider"] == "gemini"
@ -576,18 +568,15 @@ def test_google_ai_studio_provider_fields_expose_api_base():
# placeholder shows the canonical URL so users still get the visual hint.
# (See greptileai threads on PR #30419.)
assert api_base_field["default_value"] is None
assert (
api_base_field["placeholder"]
== "https://generativelanguage.googleapis.com/v1beta"
)
assert api_base_field["placeholder"] == "https://generativelanguage.googleapis.com/v1beta"
# UI forms render fields in credential_fields order; api_base should come
# first so an admin sees the URL override before the key field (matches
# OpenAI and Anthropic conventions).
field_order = [f["key"] for f in google_ai["credential_fields"]]
assert field_order.index("api_base") < field_order.index(
"api_key"
), "api_base must appear before api_key in credential_fields."
assert field_order.index("api_base") < field_order.index("api_key"), (
"api_base must appear before api_key in credential_fields."
)
def test_public_model_hub_with_healthy_model():
@ -615,20 +604,15 @@ def test_public_model_hub_with_healthy_model():
mock_llm_router = MagicMock()
mock_prisma = MagicMock()
mock_prisma.get_all_latest_health_checks = AsyncMock(
return_value=[mock_health_check]
)
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[mock_health_check])
with (
patch("litellm.public_model_groups", ["gpt-3.5-turbo"]),
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
patch(
"litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict"
) as mock_convert,
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
):
mock_get_info.return_value = [mock_model_group]
mock_convert.return_value = {
"status": "healthy",
@ -673,20 +657,15 @@ def test_public_model_hub_with_unhealthy_model():
mock_llm_router = MagicMock()
mock_prisma = MagicMock()
mock_prisma.get_all_latest_health_checks = AsyncMock(
return_value=[mock_health_check]
)
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[mock_health_check])
with (
patch("litellm.public_model_groups", ["gpt-4"]),
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
patch(
"litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict"
) as mock_convert,
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
):
mock_get_info.return_value = [mock_model_group]
mock_convert.return_value = {
"status": "unhealthy",
@ -732,7 +711,6 @@ def test_public_model_hub_without_health_check():
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
):
mock_get_info.return_value = [mock_model_group]
response = client.get(
@ -789,9 +767,7 @@ def test_public_model_hub_mixed_health_statuses():
mock_llm_router = MagicMock()
mock_prisma = MagicMock()
mock_prisma.get_all_latest_health_checks = AsyncMock(
return_value=[healthy_check, unhealthy_check]
)
mock_prisma.get_all_latest_health_checks = AsyncMock(return_value=[healthy_check, unhealthy_check])
def convert_side_effect(check):
if check.model_name == "gpt-3.5-turbo":
@ -813,11 +789,8 @@ def test_public_model_hub_mixed_health_statuses():
patch("litellm.proxy.proxy_server.get_model_group_info") as mock_get_info,
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
patch(
"litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict"
) as mock_convert,
patch("litellm.proxy.health_endpoints._health_endpoints.convert_health_check_to_dict") as mock_convert,
):
mock_get_info.return_value = [
healthy_model,
unhealthy_model,
@ -1066,9 +1039,7 @@ def test_get_supported_endpoints_provider_fields(reset_endpoints_cache):
def test_get_supported_endpoints_paths_start_with_slash(reset_endpoints_cache):
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
for item in endpoints:
assert item["endpoint"].startswith(
"/"
), f"Expected path starting with /, got: {item['endpoint']}"
assert item["endpoint"].startswith("/"), f"Expected path starting with /, got: {item['endpoint']}"
def test_get_supported_endpoints_chat_completions_present(reset_endpoints_cache):
@ -1092,9 +1063,9 @@ def test_get_supported_endpoints_display_names_have_no_slug_suffix(
endpoints = _make_client().get("/public/endpoints").json()["endpoints"]
for item in endpoints:
for provider in item["providers"]:
assert not suffix_re.search(
provider["display_name"]
), f"display_name still contains slug suffix: {provider['display_name']!r}"
assert not suffix_re.search(provider["display_name"]), (
f"display_name still contains slug suffix: {provider['display_name']!r}"
)
def test_get_supported_endpoints_is_cached(reset_endpoints_cache):
@ -1320,7 +1291,6 @@ def test_public_mcp_hub_does_not_expose_upstream_url():
app.dependency_overrides.clear()
@pytest.fixture
def reset_autorouter_presets_cache():
from litellm.proxy.public_endpoints.public_endpoints import _AutoRouterPresetsCache
@ -1332,9 +1302,7 @@ def reset_autorouter_presets_cache():
_AutoRouterPresetsCache.lock = None
def test_get_autorouter_presets_local_mode_serves_bundled_catalog(
monkeypatch, reset_autorouter_presets_cache
):
def test_get_autorouter_presets_local_mode_serves_bundled_catalog(monkeypatch, reset_autorouter_presets_cache):
monkeypatch.setenv("LITELLM_LOCAL_AUTOROUTER_PRESETS", "True")
app = FastAPI()
app.include_router(router)
@ -1362,9 +1330,7 @@ def test_get_autorouter_presets_local_mode_serves_bundled_catalog(
@pytest.mark.asyncio
async def test_get_autorouter_presets_fetches_once_per_process(
monkeypatch, reset_autorouter_presets_cache
):
async def test_get_autorouter_presets_fetches_once_per_process(monkeypatch, reset_autorouter_presets_cache):
from litellm.proxy.public_endpoints.public_endpoints import (
_AUTOROUTER_PRESETS_ADAPTER,
get_autorouter_presets,
@ -1376,7 +1342,9 @@ async def test_get_autorouter_presets_fetches_once_per_process(
"remote_only": {
"label": "Remote Only",
"description": "from the remote catalog",
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}},
"complexity_router_config": {
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
},
}
}
)
@ -1411,7 +1379,9 @@ async def test_get_autorouter_presets_single_flight_on_concurrent_cold_start(
"remote_only": {
"label": "Remote Only",
"description": "from the remote catalog",
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}},
"complexity_router_config": {
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
},
}
}
)
@ -1507,9 +1477,7 @@ async def test_autorouter_presets_adapter_rejects_wrong_shapes():
)
def test_get_autorouter_presets_passes_unknown_catalog_fields_through(
monkeypatch, reset_autorouter_presets_cache
):
def test_get_autorouter_presets_passes_unknown_catalog_fields_through(monkeypatch, reset_autorouter_presets_cache):
from litellm.proxy.public_endpoints.public_endpoints import (
_AUTOROUTER_PRESETS_ADAPTER,
_AutoRouterPresetsCache,
@ -1551,12 +1519,14 @@ async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypa
"remote_only": {
"label": "Remote Only",
"description": "from the remote catalog",
"complexity_router_config": {"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}},
"complexity_router_config": {
"tiers": {"SIMPLE": ["m1"], "MEDIUM": ["m2"], "COMPLEX": ["m3"], "REASONING": ["m4"]}
},
}
}
response = MagicMock()
response.raise_for_status = MagicMock()
response.json = MagicMock(return_value=catalog)
response.content = json.dumps(catalog).encode()
client = MagicMock()
client.get = AsyncMock(return_value=response)
monkeypatch.setattr(http_handler_module, "get_async_httpx_client", lambda llm_provider: client)
@ -1565,6 +1535,121 @@ async def test_fetch_remote_autorouter_presets_parses_and_rejects_empty(monkeypa
assert presets["remote_only"].label == "Remote Only"
response.raise_for_status.assert_called_once()
response.json = MagicMock(return_value={})
response.content = b"{}"
with pytest.raises(ValueError, match="empty"):
await _fetch_remote_autorouter_presets("https://example.test/presets.json")
@pytest.fixture
def reset_whats_new_cache() -> Iterator[None]:
from litellm.proxy.public_endpoints.public_endpoints import _WhatsNewCache
_WhatsNewCache.launches = None
_WhatsNewCache.lock = None
yield
_WhatsNewCache.launches = None
_WhatsNewCache.lock = None
def _remote_whats_new() -> WhatsNewResponse:
return WhatsNewResponse.model_validate(
{
"launches": [
{
"icon": "sparkles",
"title": "Remote Launch",
"description": "from the remote list",
"href": "https://docs.litellm.ai/blog/remote",
"published_on": "2026-10-11",
}
]
}
)
def test_get_whats_new_local_mode_serves_bundled_list(
monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None
) -> None:
monkeypatch.setenv("LITELLM_LOCAL_WHATS_NEW", "True")
app = FastAPI()
app.include_router(router)
client = TestClient(app)
response = client.get("/public/whats_new")
assert response.status_code == 200
served: Final = WhatsNewResponse.model_validate_json(response.content)
bundled: Final = WhatsNewResponse.model_validate_json(
files("litellm.proxy.public_endpoints").joinpath("whats_new.json").read_text(encoding="utf-8")
)
assert served == bundled
assert len(served.launches) > 0
@pytest.mark.asyncio
async def test_get_whats_new_fetches_once_per_process(
monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None
) -> None:
from litellm.proxy.public_endpoints.public_endpoints import get_whats_new
monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False)
remote = _remote_whats_new()
calls: Final[list[str]] = []
async def fake_fetch(url: str) -> WhatsNewResponse:
calls.append(url)
return remote
first = await get_whats_new(url="https://example.test/whats_new.json", fetch=fake_fetch)
second = await get_whats_new(url="https://example.test/whats_new.json", fetch=fake_fetch)
assert first == remote
assert second == remote
assert calls == ["https://example.test/whats_new.json"]
@pytest.mark.asyncio
async def test_get_whats_new_caches_bundled_fallback_when_remote_is_unreachable(
monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None
) -> None:
from litellm.proxy.public_endpoints.public_endpoints import _load_bundled_whats_new, get_whats_new
monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False)
calls: Final[list[str]] = []
async def unreachable_fetch(url: str) -> WhatsNewResponse:
calls.append(url)
raise OSError("network is unreachable")
first = await get_whats_new(url="https://example.test/whats_new.json", fetch=unreachable_fetch)
second = await get_whats_new(url="https://example.test/whats_new.json", fetch=unreachable_fetch)
assert first == _load_bundled_whats_new()
assert second == first
assert len(calls) == 1
@pytest.mark.asyncio
async def test_fetch_remote_whats_new_rejects_a_malformed_list_so_the_bundled_one_is_served(
monkeypatch: pytest.MonkeyPatch, reset_whats_new_cache: None
) -> None:
import httpx
from litellm.proxy.public_endpoints import public_endpoints
monkeypatch.delenv("LITELLM_LOCAL_WHATS_NEW", raising=False)
request = httpx.Request("GET", "https://example.test/whats_new.json")
client = MagicMock()
client.get = AsyncMock(
return_value=httpx.Response(
200,
request=request,
content=b'{"launches": [{"icon": "box", "title": "No date", "description": "d", "href": "https://x"}]}',
)
)
with patch("litellm.llms.custom_httpx.http_handler.get_async_httpx_client", return_value=client):
served = await public_endpoints.get_whats_new(url="https://example.test/whats_new.json")
assert served == public_endpoints._load_bundled_whats_new()
client.get.assert_awaited_once_with("https://example.test/whats_new.json", timeout=5.0)

View file

@ -6,7 +6,6 @@ import type { DailyActivityAggregatedResponse } from "@/components/UsagePage/dai
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import { all_admin_roles } from "@/utils/roles";
import HomePage from "./HomePage";
import { WHATS_NEW_ITEMS } from "./homeContent";
const auth = vi.hoisted(() => ({
accessToken: "sk-test",
@ -31,6 +30,28 @@ vi.mock("@/components/networking", async (importOriginal) => ({
const blogPosts = { posts: [{ title: "Post one", description: "d", date: "2026-10-09", url: "https://x/1" }] };
const launch = (title: string, icon: string, published_on: string) => ({
icon,
title,
description: `${title} description`,
href: `https://docs.litellm.ai/blog/${title}`,
published_on,
});
const whatsNew = vi.hoisted(() => ({ launches: [] as unknown[] }));
const requestUrl = (input: RequestInfo | URL): string => {
if (typeof input === "string") return input;
return input instanceof URL ? input.href : input.url;
};
const fakeFetch = (input: RequestInfo | URL) => {
const body = requestUrl(input).endsWith("/public/whats_new") ? whatsNew : blogPosts;
return Promise.resolve(
new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }),
);
};
const dayResult = (date: string, model: string, tokens: number) => {
const metrics = {
spend: 1,
@ -86,7 +107,8 @@ 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 } });
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(blogPosts), { status: 200 })));
whatsNew.launches = [launch("mid", "box", "2026-10-06"), launch("new", "zap", "2026-10-11")];
vi.stubGlobal("fetch", vi.fn(fakeFetch));
});
afterEach(() => {
vi.unstubAllGlobals();
@ -121,13 +143,32 @@ describe("HomePage", () => {
expect(network.aggregated).toHaveBeenCalledTimes(2);
});
it("lists the curated launches newest first with their links", () => {
it("renders the launches served by /public/whats_new newest first, whatever order the JSON lists them in", async () => {
whatsNew.launches = [
launch("mid", "box", "2026-10-06"),
launch("new", "an-icon-from-a-newer-list", "2026-10-11"),
launch("old", "scale", "2026-09-30"),
];
renderHome();
const section = screen.getByTestId("home-whats-new");
const links = within(section).getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual(WHATS_NEW_ITEMS.map((item) => item.href));
const dates = WHATS_NEW_ITEMS.map((item) => item.publishedOn);
expect(dates).toEqual([...dates].sort().reverse());
const section = await screen.findByTestId("home-whats-new");
expect(await within(section).findByText("new")).toBeInTheDocument();
expect(
within(section)
.getAllByRole("link")
.map((link) => link.getAttribute("href")),
).toEqual([
"https://docs.litellm.ai/blog/new",
"https://docs.litellm.ai/blog/mid",
"https://docs.litellm.ai/blog/old",
]);
expect(within(section).getByText("Oct 11")).toBeInTheDocument();
});
it("hides What's new when /public/whats_new has no launches", async () => {
whatsNew.launches = [];
renderHome();
expect(await screen.findByRole("link", { name: /post one/i })).toBeInTheDocument();
expect(screen.queryByTestId("home-whats-new")).not.toBeInTheDocument();
});
it("ranks model groups by token share on the leaderboard", async () => {

View file

@ -4,21 +4,24 @@ import { ArrowRight, Plus, Sparkles } from "lucide-react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Page } from "@/components/shared/Page";
import { PageHeader, PageHeaderControls, PageHeaderTitle } from "@/components/shared/PageHeader";
import { uiHref } from "@/utils/uiHref";
import { Leaderboard, useBreakdown } from "@/app/(dashboard)/usage/_components/components/overview/BreakdownChart";
import { Panel } from "@/app/(dashboard)/usage/_components/components/overview/Primitives";
import UsageStatStrip from "@/app/(dashboard)/usage/_components/components/overview/UsageStatStrip";
import { formatPublishedOn, WHATS_NEW_ITEMS } from "./homeContent";
import { formatPublishedOn, launchIcon } from "./homeContent";
import UpdatesFeed from "./UpdatesFeed";
import { HOME_USAGE_DAYS, useHomeUsage } from "./useHomeUsage";
import { useWhatsNew } from "./useWhatsNew";
const LEADERBOARD = { metric: "tokens", dimension: "model_groups" } as const;
export default function HomePage() {
const { isViewOnly } = useAuthorized();
const usage = useHomeUsage();
const whatsNew = useWhatsNew();
const { series, ranking } = useBreakdown(usage.results, LEADERBOARD, 8);
return (
@ -56,34 +59,40 @@ 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">
<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">
<Sparkles className="size-4 text-muted-foreground" />
What&apos;s new in LiteLLM?
</h2>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{WHATS_NEW_ITEMS.map((item) => {
const Icon = item.icon;
return (
<a key={item.title} href={item.href} 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" />
</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="mt-auto flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span>{formatPublishedOn(item.publishedOn)}</span>
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</div>
</Card>
</a>
);
})}
</div>
</section>
{(whatsNew.isLoading || (whatsNew.data?.length ?? 0) > 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">
<Sparkles className="size-4 text-muted-foreground" />
What&apos;s new in LiteLLM?
</h2>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{whatsNew.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);
return (
<a key={item.title} href={item.href} 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" />
</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="mt-auto flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span>{formatPublishedOn(item.published_on)}</span>
<ArrowRight className="size-3.5 transition-transform group-hover:translate-x-0.5" />
</div>
</Card>
</a>
);
})}
</div>
</section>
)}
<Panel
title="Leaderboard"

View file

@ -0,0 +1,40 @@
import { Box, Sparkles } from "lucide-react";
import { describe, expect, it } from "vitest";
import { latestLaunches, launchIcon, type WhatsNewLaunch } from "./homeContent";
const launch = (title: string, published_on: string): WhatsNewLaunch => ({
icon: "box",
title,
description: "d",
href: `https://docs.litellm.ai/${title}`,
published_on,
});
describe("latestLaunches", () => {
it("orders launches by published_on, newest first, without mutating the input", () => {
const launches = [launch("old", "2026-09-01"), launch("new", "2026-10-11"), launch("mid", "2026-10-01")];
expect(latestLaunches(launches).map((l) => l.title)).toEqual(["new", "mid", "old"]);
expect(launches.map((l) => l.title)).toEqual(["old", "new", "mid"]);
});
it("keeps only the three newest so the cards fill one row", () => {
const launches = [
launch("oldest", "2026-08-01"),
launch("a", "2026-10-03"),
launch("b", "2026-10-02"),
launch("c", "2026-10-01"),
];
expect(latestLaunches(launches).map((l) => l.title)).toEqual(["a", "b", "c"]);
});
});
describe("launchIcon", () => {
it("maps a known icon name to its lucide icon", () => {
expect(launchIcon("box")).toBe(Box);
});
it("falls back to Sparkles for an icon name this dashboard version does not know", () => {
expect(launchIcon("hologram")).toBe(Sparkles);
expect(launchIcon("constructor")).toBe(Sparkles);
});
});

View file

@ -1,40 +1,26 @@
import { Box, Scale, Zap, type LucideIcon } from "lucide-react";
import { Bot, Box, Gauge, Plug, Rocket, Scale, Shield, Sparkles, Zap, type LucideIcon } from "lucide-react";
import type { components } from "@/lib/http/schema";
export interface Launch {
readonly icon: LucideIcon;
readonly title: string;
readonly description: string;
readonly href: string;
readonly publishedOn: string;
}
export type WhatsNewLaunch = components["schemas"]["WhatsNewLaunch"];
const LAUNCHES: readonly Launch[] = [
{
icon: Box,
title: "Claude Haiku 5.5",
description: "Anthropic's newest small model, with day 0 pricing on Anthropic, Bedrock and Vertex AI.",
href: "https://docs.litellm.ai/blog/claude-haiku-5-5",
publishedOn: "2026-10-07",
},
{
icon: Scale,
title: "Decision Models",
description: "Call decision models at /v1/decisions and try them in the Decisions playground.",
href: "https://docs.litellm.ai/docs/decisions",
publishedOn: "2026-10-07",
},
{
icon: Zap,
title: "OpenAI Ultrafast",
description: "service_tier: ultrafast on GPT-6 Astra and GPT-6.1 Sol, including /ultrafast in Codex.",
href: "https://docs.litellm.ai/docs/providers/openai/ultrafast",
publishedOn: "2026-10-06",
},
];
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 WHATS_NEW_ITEMS: readonly Launch[] = [...LAUNCHES].sort((a, b) =>
b.publishedOn.localeCompare(a.publishedOn),
);
export const launchIcon = (name: string): LucideIcon => LAUNCH_ICONS.get(name) ?? Sparkles;
export const WHATS_NEW_MAX = 3;
export const latestLaunches = (launches: readonly WhatsNewLaunch[]): readonly WhatsNewLaunch[] =>
[...launches].sort((a, b) => b.published_on.localeCompare(a.published_on)).slice(0, WHATS_NEW_MAX);
export const formatPublishedOn = (isoDate: string): string =>
new Date(`${isoDate}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" });

View file

@ -0,0 +1,10 @@
import { $api } from "@/lib/http/api";
import { latestLaunches } from "./homeContent";
export const useWhatsNew = () =>
$api.useQuery(
"get",
"/public/whats_new",
{},
{ staleTime: 60 * 60 * 1000, select: (data) => latestLaunches(data.launches) },
);

View file

@ -13658,6 +13658,31 @@ export interface paths {
patch?: never;
trace?: never;
};
"/public/whats_new": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Public Whats New
* @description Return the launches the dashboard Home page shows under What's new.
*
* Resolved once per process: fetched from ``litellm.whats_new_url`` (override with ``LITELLM_WHATS_NEW_URL``)
* on the first request, falling back to the list bundled with the package on any failure, so an airgapped
* proxy serves the bundled list without retrying. Set ``LITELLM_LOCAL_WHATS_NEW=True`` to skip the fetch.
* A restart picks up a newly published list.
*/
get: operations["get_public_whats_new_public_whats_new_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/queue/chat/completions": {
parameters: {
query?: never;
@ -49245,6 +49270,30 @@ export interface components {
type: "web_search" | "web_search_2025_08_26";
user_location?: components["schemas"]["openai__types__responses__web_search_tool_param__UserLocation"] | null;
};
/**
* WhatsNewLaunch
* @description One launch card in the dashboard Home page's What's new section.
*/
WhatsNewLaunch: {
/** Description */
description: string;
/** Href */
href: string;
/** Icon */
icon: string;
/**
* Published On
* Format: date
*/
published_on: string;
/** Title */
title: string;
};
/** WhatsNewResponse */
WhatsNewResponse: {
/** Launches */
launches: components["schemas"]["WhatsNewLaunch"][];
};
/** WorkerRegistryEntry */
WorkerRegistryEntry: {
/** Name */
@ -68630,6 +68679,26 @@ export interface operations {
};
};
};
get_public_whats_new_public_whats_new_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["WhatsNewResponse"];
};
};
};
};
async_queue_request_queue_chat_completions_post: {
parameters: {
query?: {