diff --git a/litellm/__init__.py b/litellm/__init__.py
index a611b90bc37..34f08da3c89 100644
--- a/litellm/__init__.py
+++ b/litellm/__init__.py
@@ -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
diff --git a/litellm/proxy/management_helpers/auto_router_permissions.py b/litellm/proxy/management_helpers/auto_router_permissions.py
index 603b36d9406..a4428eb3a85 100644
--- a/litellm/proxy/management_helpers/auto_router_permissions.py
+++ b/litellm/proxy/management_helpers/auto_router_permissions.py
@@ -76,6 +76,7 @@ class _MemberOpenSourceClassifierConfig(LiteLLMBaseModel):
model_config = ConfigDict(extra="forbid")
+ deployment_name: str | None = None
provider: Literal["jev", "laya", "bespoke", "databricks"] = "jev"
model: str
api_key: None = None
diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py
index 47caacdcfff..75e8a5af6a5 100644
--- a/litellm/proxy/public_endpoints/public_endpoints.py
+++ b/litellm/proxy/public_endpoints/public_endpoints.py
@@ -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"],
diff --git a/litellm/proxy/public_endpoints/whats_new.json b/litellm/proxy/public_endpoints/whats_new.json
new file mode 100644
index 00000000000..40affb71e98
--- /dev/null
+++ b/litellm/proxy/public_endpoints/whats_new.json
@@ -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"
+ }
+ ]
+}
diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py
index a5f00aa7e25..7ef31f7a0a2 100644
--- a/litellm/router_strategy/complexity_router/complexity_router.py
+++ b/litellm/router_strategy/complexity_router/complexity_router.py
@@ -123,6 +123,7 @@ from .jev_classifier import (
HttpJevClassifierClient,
JevClassifierClient,
JevVerdict,
+ RouterJevClassifierClient,
build_jev_request,
jev_classifier_cost,
)
@@ -1331,8 +1332,9 @@ class ComplexityRouter(CustomLogger):
- Question complexity (multiple questions)
"""
- @staticmethod
- def _build_jev_client(config: OpenSourceClassifierConfig) -> JevClassifierClient:
+ def _build_jev_client(self, config: OpenSourceClassifierConfig) -> JevClassifierClient:
+ if config.deployment_name is not None:
+ return RouterJevClassifierClient(self.litellm_router_instance, config.deployment_name)
if config.provider in ("laya", "bespoke"):
from litellm.llms.oss_decision import oss_connection
@@ -2318,13 +2320,23 @@ class ComplexityRouter(CustomLogger):
if not self._tier_pools().get(tier_name):
raise ValueError(f"Jev classifier returned tier {tier_name!r}, which has no models configured")
model: Final = response.model or config.model
- accounting_provider: Final = "typesafe" if config.provider == "jev" else config.provider
+ accounting_provider: Final = (
+ response.classifier_provider
+ if config.deployment_name and response.classifier_provider
+ else "typesafe"
+ if config.provider == "jev"
+ else config.provider
+ )
verdict: Final = JevVerdict(
label=answer.choice,
probabilities=answer.probabilities,
confidence=answer.confidence,
model=model,
- cost=jev_classifier_cost(response, config.model, accounting_provider),
+ cost=response.classifier_cost
+ if config.deployment_name
+ else jev_classifier_cost(
+ response, config.model, "typesafe" if config.provider == "jev" else config.provider
+ ),
provider=accounting_provider,
)
if breaker is not None and permit is not None:
diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py
index 36c19e37b8d..992c098bc80 100644
--- a/litellm/router_strategy/complexity_router/config.py
+++ b/litellm/router_strategy/complexity_router/config.py
@@ -716,6 +716,7 @@ _ENVIRONMENT_KEY_SCOPE: Final = MappingProxyType(
class OpenSourceClassifierConfig(LiteLLMBaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
+ deployment_name: str | None = Field(default=None, min_length=1)
provider: Literal["jev", "laya", "bespoke", "databricks"] = "jev"
model: str = "jev-latest"
api_key: str | None = Field(default=None, description="Provider API key; optional for self-hosted providers")
@@ -754,6 +755,12 @@ class OpenSourceClassifierConfig(LiteLLMBaseModel):
@model_validator(mode="after")
def _keep_the_environment_key_on_the_environment_base(self) -> "OpenSourceClassifierConfig":
+ if self.deployment_name is not None:
+ if not self.deployment_name.strip():
+ raise ValueError("deployment_name must name a configured decision model")
+ if self.api_key is not None or self.api_base is not None:
+ raise ValueError("deployment_name uses the deployment connection; omit api_key and api_base")
+ return self
if self.provider in ("laya", "bespoke"):
from litellm.llms.oss_decision import validate_oss_api_base, validate_oss_model
@@ -833,7 +840,10 @@ def _resolve_normalized_complexity_router_config_write(
{
key: value
for key, value in existing.items()
- if same_provider and key in ("api_key", "api_base") and (key != "api_key" or same_base)
+ if same_provider
+ and not classifier.get("deployment_name")
+ and key in ("api_key", "api_base")
+ and (key != "api_key" or same_base)
}
)
return ComplexityRouterConfigWrite(
diff --git a/litellm/router_strategy/complexity_router/jev_classifier.py b/litellm/router_strategy/complexity_router/jev_classifier.py
index 65483b0a631..b8b3b4aadba 100644
--- a/litellm/router_strategy/complexity_router/jev_classifier.py
+++ b/litellm/router_strategy/complexity_router/jev_classifier.py
@@ -1,7 +1,7 @@
from collections.abc import Mapping
from datetime import datetime, timezone
from types import MappingProxyType
-from typing import Annotated, Final, Literal, NamedTuple, Protocol, TypeAlias
+from typing import TYPE_CHECKING, Annotated, Final, Literal, NamedTuple, Protocol, TypeAlias
from uuid import uuid4
import httpx
@@ -10,6 +10,7 @@ from pydantic import ConfigDict, Field, TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_router_logger
from litellm.constants import INTERNAL_CALL_ORIGIN_METADATA_KEY
+from litellm.litellm_core_utils.classifier_logging import masked_originating_request
from litellm.litellm_core_utils.internal_call_metadata import (
effective_turn_off_message_logging,
forwarded_internal_call_metadata,
@@ -22,9 +23,13 @@ from litellm.llms.databricks.decisions.transformation import DATABRICKS_DECISION
from litellm.llms.laya.common_utils import laya_response_model
from litellm.llms.pass_through.typesafe_logging_handler import TypeSafePassthroughLoggingHandler
from litellm.router_strategy.complexity_router.config import DEFAULT_JEV_INSTRUCTIONS as _DEFAULT_JEV_INSTRUCTIONS
+from litellm.types.decisions import DecisionsResponse
from litellm.types.llms.base import LiteLLMBaseModel
from litellm.types.utils import AUTOROUTER_CLASSIFIER_CALL_ORIGIN
+if TYPE_CHECKING:
+ from litellm.router import Router
+
JevProbability: TypeAlias = Annotated[float, Field(ge=0.0, le=1.0)]
ClassifierProvider: TypeAlias = Literal["typesafe", "laya", "bespoke", "databricks"]
DEFAULT_JEV_INSTRUCTIONS: Final = _DEFAULT_JEV_INSTRUCTIONS
@@ -67,6 +72,8 @@ class JevSystemOneResponse(LiteLLMBaseModel):
model: str | None = None
answers: Mapping[str, JevChoiceAnswer]
+ classifier_cost: float | None = None
+ classifier_provider: str | None = None
usage: JevUsage | None = None
@@ -79,6 +86,76 @@ class JevClassifierClient(Protocol):
) -> JevSystemOneResponse: ...
+class _DecisionsRouter(Protocol):
+ async def adecisions(
+ self,
+ *,
+ model: str,
+ state: str,
+ questions: Mapping[str, object],
+ timeout: float,
+ num_retries: int,
+ disable_fallbacks: bool,
+ metadata: Mapping[str, object],
+ proxy_server_request: Mapping[str, object],
+ turn_off_message_logging: bool | None,
+ litellm_session_id: str = "",
+ litellm_trace_id: str = "",
+ ) -> object: ...
+
+
+_PARENT_METADATA: Final = TypeAdapter[Mapping[str, object] | None](Mapping[str, object] | None)
+
+
+class RouterJevClassifierClient:
+ def __init__(self, router: "Router", deployment_name: str) -> None:
+ self._router: _DecisionsRouter = router
+ self._deployment_name = deployment_name
+
+ async def evaluate(
+ self,
+ request: JevSystemOneRequest,
+ timeout_s: float,
+ request_kwargs: Mapping[str, object] | None = None,
+ ) -> JevSystemOneResponse:
+ parent: Final = request_kwargs or MappingProxyType({})
+ metadata: Final = {
+ **forwarded_internal_call_metadata(
+ _PARENT_METADATA.validate_python(parent.get("litellm_metadata") or parent.get("metadata")),
+ AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
+ ),
+ INTERNAL_CALL_ORIGIN_METADATA_KEY: AUTOROUTER_CLASSIFIER_CALL_ORIGIN,
+ }
+ payload: Final = {
+ "state": request.state,
+ "questions": {key: value.model_dump() for key, value in request.questions.items()},
+ }
+ response: Final = DecisionsResponse.model_validate(
+ await self._router.adecisions(
+ model=self._deployment_name,
+ state=request.state,
+ questions={key: value.model_dump() for key, value in request.questions.items()},
+ timeout=timeout_s,
+ num_retries=0,
+ disable_fallbacks=True,
+ metadata=metadata,
+ proxy_server_request={
+ "originating_request_masked": masked_originating_request(request_kwargs),
+ "body": {"model": self._deployment_name, **payload},
+ },
+ turn_off_message_logging=effective_turn_off_message_logging(request_kwargs),
+ **parent_session_kwargs(request_kwargs),
+ )
+ )
+ return JevSystemOneResponse.model_validate(
+ {
+ **response.model_dump(),
+ "classifier_cost": response.hidden_params.get("response_cost"),
+ "classifier_provider": response.hidden_params.get("custom_llm_provider"),
+ }
+ )
+
+
class HttpJevClassifierClient:
def __init__(
self,
@@ -212,7 +289,7 @@ class JevVerdict(NamedTuple):
confidence: float
model: str
cost: float | None
- provider: ClassifierProvider = "typesafe"
+ provider: str = "typesafe"
class _RegistryPricing(LiteLLMBaseModel):
diff --git a/litellm/router_utils/auto_router_model_naming.py b/litellm/router_utils/auto_router_model_naming.py
index 7ace4094cf0..3d78af94cde 100644
--- a/litellm/router_utils/auto_router_model_naming.py
+++ b/litellm/router_utils/auto_router_model_naming.py
@@ -169,9 +169,13 @@ def strategy_router_dependencies(
else ()
)
+ (
- _named(
- f"{accounting_provider}/{decision_classifier.get('model', 'jev-latest')}",
- "evaluation",
+ (
+ _named(decision_classifier.get("deployment_name"), "classifier")
+ if decision_classifier.get("deployment_name")
+ else _named(
+ f"{accounting_provider}/{decision_classifier.get('model', 'jev-latest')}",
+ "evaluation",
+ )
)
if complexity.get("classifier_type") == "oss_classifier"
else ()
diff --git a/litellm/types/proxy/public_endpoints/public_endpoints.py b/litellm/types/proxy/public_endpoints/public_endpoints.py
index c487ce8b6c5..6e4adb4bbe7 100644
--- a/litellm/types/proxy/public_endpoints/public_endpoints.py
+++ b/litellm/types/proxy/public_endpoints/public_endpoints.py
@@ -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.
diff --git a/pyproject.toml b/pyproject.toml
index 133fc735df1..0ed8b8757ac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
]
diff --git a/tests/unit/proxy/management_helpers/test_auto_router_permissions.py b/tests/unit/proxy/management_helpers/test_auto_router_permissions.py
index 5ef1399b0cb..16ddf009b0b 100644
--- a/tests/unit/proxy/management_helpers/test_auto_router_permissions.py
+++ b/tests/unit/proxy/management_helpers/test_auto_router_permissions.py
@@ -427,3 +427,26 @@ async def test_jev_evaluation_obeys_each_containing_scope(
return
await operation
assert not catalog.get_model_list(f"{provider}/{model}")
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("restricted", ["key", "team", None])
+async def test_deployed_decision_classifier_requires_key_and_team_access(catalog: Router, restricted: str | None) -> None:
+ permitted: Final = ["allowed", "other"]
+ operation: Final = authorize_member_auto_router_dependencies(
+ config=validate_member_auto_router_config({
+ "tiers": {"SIMPLE": "allowed"},
+ "classifier_type": "oss_classifier",
+ "opensource_classifier_config": {"deployment_name": "other"},
+ }),
+ default_model=None,
+ user_api_key_dict=_actor(models=["allowed"] if restricted == "key" else permitted),
+ team=_team(models=["allowed"] if restricted == "team" else permitted),
+ prisma_client=_Client(),
+ llm_router=catalog,
+ )
+ if restricted is not None:
+ with pytest.raises(ProxyException, match="other"):
+ await operation
+ return
+ await operation
diff --git a/tests/unit/proxy/public_endpoints/test_public_endpoints.py b/tests/unit/proxy/public_endpoints/test_public_endpoints.py
index b720ba24c3e..0779f692b17 100644
--- a/tests/unit/proxy/public_endpoints/test_public_endpoints.py
+++ b/tests/unit/proxy/public_endpoints/test_public_endpoints.py
@@ -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)
diff --git a/tests/unit/router_strategy/complexity_router/test_jev_classifier.py b/tests/unit/router_strategy/complexity_router/test_jev_classifier.py
index fea283cbe9c..c1c96588004 100644
--- a/tests/unit/router_strategy/complexity_router/test_jev_classifier.py
+++ b/tests/unit/router_strategy/complexity_router/test_jev_classifier.py
@@ -573,7 +573,11 @@ async def test_databricks_routes_through_the_serving_endpoint_and_accounts_the_e
"jev_classifier_config" if legacy else "opensource_classifier_config": {
"provider": "databricks",
"model": endpoint,
- **({"api_key": "dapi-configured", "api_base": "https://workspace.test/serving-endpoints"} if configured_key else {}),
+ **(
+ {"api_key": "dapi-configured", "api_base": "https://workspace.test/serving-endpoints"}
+ if configured_key
+ else {}
+ ),
},
"tiers": {"SIMPLE": "cheap"},
},
@@ -596,7 +600,9 @@ async def test_databricks_routes_through_the_serving_endpoint_and_accounts_the_e
assert (outcome.jev_verdict.provider, outcome.jev_verdict.model) == ("databricks", endpoint)
assert outcome.classifier_cost == pytest.approx(0.31)
sent: Final = route.calls.last.request
- assert sent.headers.get("authorization") == ("Bearer dapi-configured" if configured_key else "Bearer dapi-env-token")
+ assert sent.headers.get("authorization") == (
+ "Bearer dapi-configured" if configured_key else "Bearer dapi-env-token"
+ )
assert json.loads(sent.content)["model"] == endpoint
assert len(recorder.calls) == 1
assert recorder.calls[0]["response_cost"] == pytest.approx(0.31)
@@ -765,3 +771,143 @@ async def test_http_jev_classifier_client_posts_to_system_one() -> None:
assert captured["content_type"] == "application/json"
assert captured["body"] == request.model_dump(mode="json")
assert response.model == "jev-1.13.0"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("status_code", [200, 503])
+@pytest.mark.parametrize("provider,model", [("typesafe", "jev-latest"), ("databricks", "my-endpoint")])
+async def test_deployed_decision_classifier_uses_registered_connection(
+ status_code: int, provider: str, model: str, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
+ monkeypatch.setitem(litellm.model_cost, f"{provider}/{model}", {"input_cost_per_token": 0.01})
+ router: Final = litellm.Router(
+ model_list=[
+ {
+ "model_name": "decision-deployment",
+ "litellm_params": {
+ "model": f"{provider}/{model}",
+ "api_key": "deployment-key",
+ "api_base": "https://decision.test/serving-endpoints"
+ if provider == "databricks"
+ else "https://decision.test",
+ },
+ }
+ ],
+ num_retries=0,
+ )
+ classifier: Final = ComplexityRouter(
+ "deployed-classifier",
+ router,
+ {
+ "classifier_type": "oss_classifier",
+ "opensource_classifier_config": {"deployment_name": "decision-deployment"},
+ "tiers": {"SIMPLE": "cheap", "MEDIUM": "mid", "COMPLEX": "strong", "REASONING": "top"},
+ },
+ derive_savings_baseline=False,
+ )
+ with respx.mock(assert_all_called=True) as requests:
+ route: Final = requests.post(
+ "https://decision.test/serving-endpoints/my-endpoint/invocations"
+ if provider == "databricks"
+ else "https://decision.test/v1/systemone"
+ ).mock(
+ return_value=httpx.Response(
+ status_code,
+ json={
+ "model": model,
+ "answers": {"tier": _answer().model_dump()},
+ "usage": {"input_tokens": 31, "output_tokens": 0},
+ },
+ )
+ )
+ outcome: Final = await classifier.aclassify("What is two plus two?")
+ assert route.call_count == 1
+ assert route.calls[0].request.headers["authorization"] == "Bearer deployment-key"
+ assert json.loads(route.calls[0].request.content)["model"] == model
+ assert (outcome.cause == "jev_classifier") is (status_code == 200)
+ if status_code == 200:
+ assert outcome.jev_verdict is not None
+ assert outcome.jev_verdict.label == _answer().choice
+
+ assert outcome.jev_verdict.provider == provider
+ assert outcome.classifier_cost == pytest.approx(0.31)
+
+
+@pytest.mark.parametrize("deployment_name", ["", " "])
+def test_deployed_decision_classifier_rejects_blank_deployment_name(deployment_name: str) -> None:
+ with pytest.raises(ValueError, match="deployment_name"):
+ JevClassifierConfig.model_validate({"deployment_name": deployment_name})
+
+
+@pytest.mark.parametrize("connection", [{"api_key": "secret"}, {"api_base": "https://override.test"}])
+def test_deployed_decision_classifier_rejects_connection_overrides(connection: Mapping[str, str]) -> None:
+ with pytest.raises(ValueError, match="deployment connection"):
+ JevClassifierConfig.model_validate({"deployment_name": "deployed", **connection})
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("failure", ["timeout", "cancelled", "invalid"])
+async def test_deployed_decision_classifier_preserves_failure_handling(
+ failure: str, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
+ router: Final = litellm.Router(
+ model_list=[
+ {
+ "model_name": "decision-deployment",
+ "litellm_params": {
+ "model": "typesafe/jev-latest",
+ "api_key": "test",
+ "api_base": "https://decision.test",
+ },
+ }
+ ],
+ num_retries=0,
+ )
+ classifier: Final = ComplexityRouter(
+ "decision-router",
+ router,
+ {
+ "classifier_type": "oss_classifier",
+ "opensource_classifier_config": {"deployment_name": "decision-deployment"},
+ "tiers": {"SIMPLE": "cheap", "MEDIUM": "mid", "COMPLEX": "strong", "REASONING": "top"},
+ },
+ derive_savings_baseline=False,
+ )
+ cancellation_reached: Final = asyncio.Event()
+ with respx.mock(assert_all_called=failure != "cancelled") as upstream:
+ route: Final = upstream.post("https://decision.test/v1/systemone")
+ if failure == "timeout":
+ route.mock(side_effect=httpx.ReadTimeout("classifier timeout"))
+ elif failure == "cancelled":
+
+ async def cancel_request(request: httpx.Request) -> httpx.Response:
+ cancellation_reached.set()
+ raise asyncio.CancelledError()
+
+ route.mock(side_effect=cancel_request)
+ else:
+ route.respond(json={"answers": {"tier": _answer("not-a-tier").model_dump()}})
+ if failure == "cancelled":
+ with pytest.raises(asyncio.CancelledError):
+ await classifier.aclassify("short question")
+ assert cancellation_reached.is_set()
+ return
+ outcome: Final = await classifier.aclassify("short question")
+ assert outcome.cause != "jev_classifier"
+ assert outcome.jev_verdict is None
+
+
+def test_switching_to_deployed_classifier_does_not_copy_legacy_credentials() -> None:
+ from litellm.router_strategy.complexity_router.config import resolve_complexity_router_config_write
+
+ write: Final = resolve_complexity_router_config_write(
+ {"classifier_type": "oss_classifier", "opensource_classifier_config": {"deployment_name": "registered"}},
+ {
+ "classifier_type": "oss_classifier",
+ "opensource_classifier_config": {"api_key": "legacy-secret", "api_base": "https://legacy.test"},
+ },
+ )
+ assert write.effective is not None
+ assert write.effective["opensource_classifier_config"] == {"deployment_name": "registered"}
diff --git a/tests/unit/router_utils/test_auto_router_model_naming.py b/tests/unit/router_utils/test_auto_router_model_naming.py
index d7016cbc9ec..1cfa3aa5c91 100644
--- a/tests/unit/router_utils/test_auto_router_model_naming.py
+++ b/tests/unit/router_utils/test_auto_router_model_naming.py
@@ -755,3 +755,20 @@ def test_chained_heuristic_and_judge_prompt_claim_each_active_capability(
assert count_capability_routers(({"litellm_params": params},), capability=capability) == (
1 if capability.key in expected else 0
)
+
+
+def test_deployed_decision_classifier_is_a_model_access_dependency() -> None:
+ dependencies: Final = strategy_router_dependencies(
+ {
+ "model": "auto_router/complexity_router",
+ "complexity_router_config": {
+ "classifier_type": "oss_classifier",
+ "opensource_classifier_config": {"deployment_name": "decision-deployment"},
+ "tiers": {"SIMPLE": "cheap"},
+ },
+ }
+ )
+ assert tuple((dependency.model_name, dependency.role) for dependency in dependencies) == (
+ ("cheap", "tier"),
+ ("decision-deployment", "classifier"),
+ )
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx
index ad3f80b7d78..a17a18beee5 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.integration.test.tsx
@@ -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 () => {
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx
index f4519958e9d..bcae30795c6 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/HomePage.tsx
@@ -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() {
-
-
-
-
- What's new in LiteLLM?
-
-
-
-
+ {(whatsNew.isLoading || (whatsNew.data?.length ?? 0) > 0) && (
+
+
+
+
+ What's new in LiteLLM?
+
+
+
+
+ )}
({
+ 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);
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts
index 41d1aac25de..c3eb4b2c838 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts
+++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/homeContent.ts
@@ -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 = 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" });
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useWhatsNew.ts b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useWhatsNew.ts
new file mode 100644
index 00000000000..c2171f3349a
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/home/_components/useWhatsNew.ts
@@ -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) },
+ );
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
index 607b676d304..6f7e04f92c6 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AutoRouters/AutoRoutersPanel.tsx
@@ -100,7 +100,10 @@ export function AutoRoutersPanel({