mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-15 04:19:08 +00:00
Merge pull request #45911 from BerriAI/litellm_backport_home_page_rc_1_106_0
feat(ui): backport the Home page dashboard root to rc/1.106.0
This commit is contained in:
commit
d5a344411f
30 changed files with 1072 additions and 181 deletions
|
|
@ -439,6 +439,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
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ from litellm import verbose_logger
|
|||
from litellm.types.llms.base import LiteLLMBaseModel
|
||||
|
||||
BLOG_POSTS_TTL_SECONDS: Final[int] = 3600 # 1 hour
|
||||
BLOG_POSTS_MAX: Final[int] = 6
|
||||
|
||||
|
||||
class BlogPost(LiteLLMBaseModel):
|
||||
|
|
@ -40,7 +41,7 @@ class GetBlogPosts:
|
|||
Fetches, validates, and caches LiteLLM blog posts.
|
||||
|
||||
- Fetches RSS feed from docs site with a 5-second timeout
|
||||
- Parses the XML and extracts the latest blog post
|
||||
- Parses the XML and extracts the latest BLOG_POSTS_MAX blog posts
|
||||
- Caches the result in-process for BLOG_POSTS_TTL_SECONDS (1 hour)
|
||||
- Falls back to the bundled local backup on any failure
|
||||
"""
|
||||
|
|
@ -66,7 +67,7 @@ class GetBlogPosts:
|
|||
return response.text
|
||||
|
||||
@staticmethod
|
||||
def parse_rss_to_posts(xml_text: str, max_posts: int = 1) -> list[dict[str, str]]:
|
||||
def parse_rss_to_posts(xml_text: str, max_posts: int = BLOG_POSTS_MAX) -> list[dict[str, str]]:
|
||||
"""
|
||||
Parse RSS XML and return a list of blog post dicts.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -490,7 +492,7 @@ async def get_litellm_blog_posts():
|
|||
verbose_logger.warning("LiteLLM: get_litellm_blog_posts endpoint fallback triggered: %s", str(e))
|
||||
posts_data = GetBlogPosts.load_local_blog_posts()
|
||||
|
||||
posts: Final = [BlogPost(**p) for p in posts_data[:5]]
|
||||
posts: Final = [BlogPost(**p) for p in posts_data]
|
||||
return BlogPostsResponse(posts=posts)
|
||||
|
||||
|
||||
|
|
@ -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"],
|
||||
|
|
|
|||
25
litellm/proxy/public_endpoints/whats_new.json
Normal file
25
litellm/proxy/public_endpoints/whats_new.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"litellm/proxy/lens/prompts/*.md",
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ def test_get_blog_posts_returns_response_shape(client):
|
|||
assert post["url"] == "https://www.litellm.ai/blog/test"
|
||||
|
||||
|
||||
def test_get_blog_posts_limits_to_five(client):
|
||||
"""Endpoint returns at most 5 posts."""
|
||||
def test_get_blog_posts_returns_every_fetched_post(client):
|
||||
"""The parser owns the cap (BLOG_POSTS_MAX), so the endpoint passes every fetched post through."""
|
||||
many_posts = [
|
||||
{
|
||||
"title": f"Post {i}",
|
||||
|
|
@ -64,7 +64,7 @@ def test_get_blog_posts_limits_to_five(client):
|
|||
response = client.get("/public/litellm_blog_posts")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["posts"]) == 5
|
||||
assert [p["title"] for p in response.json()["posts"]] == [f"Post {i}" for i in range(10)]
|
||||
|
||||
|
||||
def test_get_blog_posts_returns_local_backup_on_failure(client):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -2,17 +2,18 @@
|
|||
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.litellm_core_utils.get_blog_posts import (
|
||||
BLOG_POSTS_MAX,
|
||||
BlogPost,
|
||||
BlogPostsResponse,
|
||||
GetBlogPosts,
|
||||
get_blog_posts,
|
||||
)
|
||||
from xml.etree import ElementTree
|
||||
|
||||
SAMPLE_RSS = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
|
@ -71,6 +72,18 @@ def test_parse_rss_to_posts_multiple():
|
|||
assert posts[1]["title"] == "Second Post"
|
||||
|
||||
|
||||
def test_parse_rss_to_posts_default_cap():
|
||||
items = "".join(
|
||||
f"<item><title>Post {i}</title><link>https://docs.litellm.ai/blog/{i}</link>"
|
||||
f"<description>D</description><pubDate>Thu, 0{1 + i % 9} Jan 2026 00:00:00 GMT</pubDate></item>"
|
||||
for i in range(BLOG_POSTS_MAX + 1)
|
||||
)
|
||||
posts = GetBlogPosts.parse_rss_to_posts(f"<rss><channel>{items}</channel></rss>")
|
||||
assert len(posts) == BLOG_POSTS_MAX
|
||||
assert posts[0]["title"] == "Post 0"
|
||||
assert posts[-1]["title"] == f"Post {BLOG_POSTS_MAX - 1}"
|
||||
|
||||
|
||||
def test_parse_rss_to_posts_invalid_xml():
|
||||
with pytest.raises(ElementTree.ParseError):
|
||||
GetBlogPosts.parse_rss_to_posts("not xml")
|
||||
|
|
@ -82,9 +95,7 @@ def test_parse_rss_to_posts_missing_channel():
|
|||
|
||||
|
||||
def test_validate_blog_posts_valid():
|
||||
posts = [
|
||||
{"title": "T", "description": "D", "date": "2026-01-01", "url": "https://x.com"}
|
||||
]
|
||||
posts = [{"title": "T", "description": "D", "date": "2026-01-01", "url": "https://x.com"}]
|
||||
assert GetBlogPosts.validate_blog_posts(posts) is True
|
||||
|
||||
|
||||
|
|
@ -108,7 +119,7 @@ def test_get_blog_posts_success():
|
|||
):
|
||||
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||
|
||||
assert len(posts) == 1
|
||||
assert len(posts) == 2
|
||||
assert posts[0]["title"] == "Test Post"
|
||||
|
||||
|
||||
|
|
@ -163,9 +174,7 @@ def test_get_blog_posts_ttl_cache_not_refetched():
|
|||
m.raise_for_status = MagicMock()
|
||||
return m
|
||||
|
||||
with patch(
|
||||
"litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=mock_get
|
||||
):
|
||||
with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=mock_get):
|
||||
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||
|
||||
assert call_count == 0 # cache hit, no fetch
|
||||
|
|
@ -196,7 +205,7 @@ def test_get_blog_posts_ttl_expired_refetches():
|
|||
posts = get_blog_posts(url=litellm.blog_posts_url)
|
||||
|
||||
mock_get.assert_called_once()
|
||||
assert len(posts) == 1
|
||||
assert len(posts) == 2
|
||||
|
||||
|
||||
def test_get_blog_posts_local_env_var_skips_remote(monkeypatch):
|
||||
|
|
@ -219,9 +228,5 @@ def test_blog_post_pydantic_model():
|
|||
|
||||
|
||||
def test_blog_posts_response_pydantic_model():
|
||||
resp = BlogPostsResponse(
|
||||
posts=[
|
||||
BlogPost(title="T", description="D", date="2026-01-01", url="https://x.com")
|
||||
]
|
||||
)
|
||||
resp = BlogPostsResponse(posts=[BlogPost(title="T", description="D", date="2026-01-01", url="https://x.com")])
|
||||
assert len(resp.posts) == 1
|
||||
|
|
|
|||
|
|
@ -0,0 +1,190 @@
|
|||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DailyActivityAggregatedResponse } from "@/components/UsagePage/dailyActivityApi";
|
||||
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import HomePage from "./HomePage";
|
||||
|
||||
const auth = vi.hoisted(() => ({
|
||||
accessToken: "sk-test",
|
||||
userRole: "",
|
||||
userId: "u1",
|
||||
isViewOnly: false,
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => auth }));
|
||||
|
||||
const network = vi.hoisted(() => ({
|
||||
aggregated: vi.fn(),
|
||||
gateway: vi.fn(),
|
||||
userInfo: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/components/networking", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/components/networking")>()),
|
||||
dailyActivityAggregatedCall: (...args: unknown[]) => network.aggregated(...args),
|
||||
gatewayDailyActivityCall: (...args: unknown[]) => network.gateway(...args),
|
||||
userGetInfoV2: (...args: unknown[]) => network.userInfo(...args),
|
||||
getProxyBaseUrl: () => "http://proxy.test",
|
||||
}));
|
||||
|
||||
const blogPosts = { posts: [{ title: "Post one", description: "d", date: "2026-10-09", url: "https://x/1" }] };
|
||||
|
||||
const launch = (title: string, icon: string, published_on: string) => ({
|
||||
icon,
|
||||
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,
|
||||
prompt_tokens: tokens,
|
||||
completion_tokens: 0,
|
||||
total_tokens: tokens,
|
||||
api_requests: 1,
|
||||
successful_requests: 1,
|
||||
failed_requests: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
};
|
||||
return {
|
||||
date,
|
||||
metrics,
|
||||
breakdown: {
|
||||
models: {},
|
||||
model_groups: { [model]: { metrics, metadata: {}, api_key_breakdown: {} } },
|
||||
providers: {},
|
||||
api_keys: {},
|
||||
entities: {},
|
||||
mcp_servers: {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const activity: DailyActivityAggregatedResponse = {
|
||||
results: [
|
||||
dayResult("2026-10-08", "gpt-6.1-sol", 900),
|
||||
dayResult("2026-10-09", "claude-haiku-5-5", 100),
|
||||
] as unknown as DailyActivityAggregatedResponse["results"],
|
||||
metadata: {
|
||||
...EMPTY_DAILY_ACTIVITY_METADATA,
|
||||
total_spend: 501.74,
|
||||
total_api_requests: 5118,
|
||||
total_successful_requests: 5075,
|
||||
total_failed_requests: 43,
|
||||
total_tokens: 98_600_000,
|
||||
},
|
||||
};
|
||||
|
||||
const renderHome = () =>
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
|
||||
<HomePage />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
describe("HomePage", () => {
|
||||
beforeEach(() => {
|
||||
auth.isViewOnly = false;
|
||||
auth.userRole = all_admin_roles[0]!;
|
||||
network.aggregated.mockReset().mockResolvedValue(activity);
|
||||
network.gateway.mockReset().mockResolvedValue({ total_successful_requests: 0, total_failed_requests: 0 });
|
||||
network.userInfo.mockReset().mockResolvedValue({ user_info: { max_budget: null } });
|
||||
whatsNew.launches = [launch("mid", "box", "2026-10-06"), launch("new", "zap", "2026-10-11")];
|
||||
vi.stubGlobal("fetch", vi.fn(fakeFetch));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows the usage totals from the daily activity API and links Create New Virtual Key to the key creation flow", async () => {
|
||||
renderHome();
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Home" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("$501.74")).toBeInTheDocument();
|
||||
expect(screen.getByText("5,118")).toBeInTheDocument();
|
||||
expect(screen.getByText("98.6M")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /create new virtual key/i })).toHaveAttribute(
|
||||
"href",
|
||||
expect.stringMatching(/\/api-keys\?create=true$/),
|
||||
);
|
||||
expect(network.aggregated).toHaveBeenCalledWith("user", expect.objectContaining({ accessToken: "sk-test" }));
|
||||
});
|
||||
|
||||
it("hides Create New Virtual Key from view-only users", () => {
|
||||
auth.isViewOnly = true;
|
||||
renderHome();
|
||||
expect(screen.queryByRole("link", { name: /create new virtual key/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error with a retry instead of zero totals when the usage request fails", async () => {
|
||||
network.aggregated.mockRejectedValueOnce(new Error("boom"));
|
||||
renderHome();
|
||||
expect(await screen.findByText("Failed to load usage")).toBeInTheDocument();
|
||||
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(await screen.findByText("$501.74")).toBeInTheDocument();
|
||||
expect(network.aggregated).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("renders the launches served by /public/whats_new newest first, whatever order the JSON lists them in", async () => {
|
||||
whatsNew.launches = [
|
||||
launch("mid", "box", "2026-10-06"),
|
||||
launch("new", "an-icon-from-a-newer-list", "2026-10-11"),
|
||||
launch("old", "scale", "2026-09-30"),
|
||||
];
|
||||
renderHome();
|
||||
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 () => {
|
||||
renderHome();
|
||||
const board = screen.getByTestId("home-leaderboard");
|
||||
expect(await within(board).findByText("gpt-6.1-sol")).toBeInTheDocument();
|
||||
const rows = within(board).getAllByRole("listitem");
|
||||
expect(within(rows[0]!).getByText("gpt-6.1-sol")).toBeInTheDocument();
|
||||
expect(within(rows[0]!).getByText("90.0%")).toBeInTheDocument();
|
||||
expect(within(rows[1]!).getByText("claude-haiku-5-5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the blog posts fetched from /public/litellm_blog_posts in the Updates feed", async () => {
|
||||
renderHome();
|
||||
const updates = screen.getByTestId("home-updates");
|
||||
expect(await within(updates).findByRole("link", { name: /post one/i })).toHaveAttribute("href", "https://x/1");
|
||||
expect(fetch).toHaveBeenCalledWith("http://proxy.test/public/litellm_blog_posts");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
"use client";
|
||||
|
||||
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, 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 (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<PageHeaderTitle>Home</PageHeaderTitle>
|
||||
{!isViewOnly && (
|
||||
<PageHeaderControls>
|
||||
<Button render={<a href={uiHref("api-keys?create=true")} />}>
|
||||
<Plus />
|
||||
Create New Virtual Key
|
||||
</Button>
|
||||
</PageHeaderControls>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<section aria-label={`Usage, last ${HOME_USAGE_DAYS} days`}>
|
||||
{usage.failed ? (
|
||||
<div className="flex items-center justify-between gap-2 rounded-xl border bg-card px-4 py-3 text-sm">
|
||||
<span className="text-destructive">Failed to load usage</span>
|
||||
<Button variant="outline" size="sm" onClick={usage.retry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<UsageStatStrip
|
||||
results={usage.results}
|
||||
totals={usage.totals}
|
||||
loading={usage.loading}
|
||||
requestCountsPending={usage.requestCountsPending}
|
||||
budget={usage.budget}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,3fr)_minmax(16rem,1fr)]">
|
||||
<div className="flex min-w-0 flex-col gap-6">
|
||||
{(whatsNew.isLoading || (whatsNew.data?.length ?? 0) > 0) && (
|
||||
<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'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"
|
||||
subtitle={`Share of tokens over the last ${HOME_USAGE_DAYS} days, with the change between the first and second half of the period`}
|
||||
testId="home-leaderboard"
|
||||
>
|
||||
<Leaderboard
|
||||
ranking={ranking}
|
||||
series={series}
|
||||
metric={LEADERBOARD.metric}
|
||||
dimension={LEADERBOARD.dimension}
|
||||
columns={2}
|
||||
limit={10}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<UpdatesFeed className="self-start" />
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowUpRight, Newspaper } from "lucide-react";
|
||||
import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts";
|
||||
import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export const BLOG_URL = "https://docs.litellm.ai/blog";
|
||||
const MAX_ROWS = 6;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
const relativeTime = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
|
||||
|
||||
export const formatRelativeDate = (isoDate: string, now: Date = new Date()): string => {
|
||||
const published = new Date(`${isoDate}T00:00:00`);
|
||||
if (Number.isNaN(published.getTime())) return isoDate;
|
||||
const days = Math.max(0, Math.round((now.getTime() - published.getTime()) / DAY_MS));
|
||||
if (days < 7) return relativeTime.format(-days, "day");
|
||||
if (days < 30) return relativeTime.format(-Math.round(days / 7), "week");
|
||||
if (days < 365) return relativeTime.format(-Math.round(days / 30), "month");
|
||||
return relativeTime.format(-Math.round(days / 365), "year");
|
||||
};
|
||||
|
||||
const Row = ({ children }: { children: React.ReactNode }) => (
|
||||
<li className="flex gap-3 py-3">
|
||||
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border border-border bg-background text-muted-foreground">
|
||||
<Newspaper className="size-3.5" />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">{children}</div>
|
||||
</li>
|
||||
);
|
||||
|
||||
/** Latest posts from the LiteLLM blog RSS feed, via /public/litellm_blog_posts, one row per post. */
|
||||
export default function UpdatesFeed({ className }: { className?: string }) {
|
||||
const disabled = useDisableBlogPosts();
|
||||
const { data, isLoading, isError, refetch } = useBlogPosts();
|
||||
if (disabled) return null;
|
||||
|
||||
const posts = data?.posts.slice(0, MAX_ROWS) ?? [];
|
||||
|
||||
return (
|
||||
<section aria-labelledby="home-updates-title" data-testid="home-updates" className={className}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 id="home-updates-title" className="text-lg font-semibold">
|
||||
Updates
|
||||
</h2>
|
||||
<Button variant="ghost" size="sm" render={<a href={BLOG_URL} target="_blank" rel="noopener noreferrer" />}>
|
||||
View all
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{isLoading && (
|
||||
<ul className="m-0 list-none p-0">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<Row key={i}>
|
||||
<Skeleton className="h-3 w-16" />
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</Row>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{isError && (
|
||||
<div className="flex items-center justify-between gap-2 py-3 text-sm">
|
||||
<span className="text-destructive">Failed to load posts</span>
|
||||
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{data && posts.length === 0 && <p className="py-3 text-sm text-muted-foreground">No posts yet</p>}
|
||||
{posts.length > 0 && (
|
||||
<ol className="m-0 list-none p-0">
|
||||
{posts.map((post: BlogPost) => (
|
||||
<Row key={post.url}>
|
||||
<span className="text-xs text-muted-foreground">{formatRelativeDate(post.date)}</span>
|
||||
<a
|
||||
href={post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-semibold leading-5 text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
>
|
||||
{post.title}
|
||||
</a>
|
||||
<span className="line-clamp-2 text-sm leading-5 text-muted-foreground">{post.description}</span>
|
||||
</Row>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { Bot, Box, Gauge, Plug, Rocket, Scale, Shield, Sparkles, Zap, type LucideIcon } from "lucide-react";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
|
||||
export type WhatsNewLaunch = components["schemas"]["WhatsNewLaunch"];
|
||||
|
||||
const LAUNCH_ICONS: ReadonlyMap<string, LucideIcon> = new Map([
|
||||
["bot", Bot],
|
||||
["box", Box],
|
||||
["gauge", Gauge],
|
||||
["plug", Plug],
|
||||
["rocket", Rocket],
|
||||
["scale", Scale],
|
||||
["shield", Shield],
|
||||
["sparkles", Sparkles],
|
||||
["zap", Zap],
|
||||
]);
|
||||
|
||||
export const launchIcon = (name: string): LucideIcon => LAUNCH_ICONS.get(name) ?? Sparkles;
|
||||
|
||||
export const 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" });
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
|
||||
import { gatewayDailyActivityCall } from "@/components/networking";
|
||||
import { toDailyData } from "@/components/UsagePage/dailyActivityApi";
|
||||
import type { DailyData } from "@/components/UsagePage/types";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import { useAggregatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity";
|
||||
import { ENTITY_API } from "@/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns";
|
||||
import type { GatewayActivity } from "@/app/(dashboard)/usage/_components/components/gatewayActivity";
|
||||
import { EMPTY_DAILY_ACTIVITY_METADATA, type DailyActivityRequest } from "@/components/UsagePage/dailyActivityApi";
|
||||
import {
|
||||
overviewTotals,
|
||||
type OverviewTotals,
|
||||
} from "@/app/(dashboard)/usage/_components/components/overview/overviewData";
|
||||
|
||||
export const HOME_USAGE_DAYS = 7;
|
||||
|
||||
export interface HomeUsage {
|
||||
results: readonly DailyData[];
|
||||
totals: OverviewTotals;
|
||||
loading: boolean;
|
||||
requestCountsPending: boolean;
|
||||
failed: boolean;
|
||||
budget: number | null;
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
/** Last HOME_USAGE_DAYS of the signed-in user's view of usage: deployment-wide for admins, their own otherwise. */
|
||||
export function useHomeUsage(): HomeUsage {
|
||||
const { accessToken, userRole, userId } = useAuthorized();
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
const isAdmin = all_admin_roles.includes(userRole || "");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const range = useMemo(() => {
|
||||
const endTime = new Date();
|
||||
const startTime = new Date(endTime.getTime() - HOME_USAGE_DAYS * 24 * 60 * 60 * 1000);
|
||||
return { startTime, endTime };
|
||||
}, []);
|
||||
|
||||
const request = useMemo<DailyActivityRequest | null>(
|
||||
() =>
|
||||
accessToken && (isAdmin || userId)
|
||||
? { accessToken, ...range, entityIds: isAdmin ? null : [userId as string] }
|
||||
: null,
|
||||
[accessToken, isAdmin, range, userId],
|
||||
);
|
||||
const { data, loading, failed } = useAggregatedDailyActivity({
|
||||
fetch: () => ENTITY_API.user.aggregated(request as DailyActivityRequest),
|
||||
enabled: request !== null,
|
||||
deps: [accessToken, isAdmin, userId, range.startTime.toISOString(), attempt],
|
||||
});
|
||||
|
||||
const [gateway, setGateway] = useState<GatewayActivity | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isAdmin || !accessToken) return;
|
||||
let cancelled = false;
|
||||
gatewayDailyActivityCall(accessToken, range.startTime, range.endTime)
|
||||
.then((activity: GatewayActivity) => {
|
||||
const recorded = activity.total_successful_requests + activity.total_failed_requests > 0;
|
||||
if (!cancelled) setGateway(recorded ? activity : null);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setGateway(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isAdmin, accessToken, range, attempt]);
|
||||
|
||||
const results = useMemo(() => toDailyData(data), [data]);
|
||||
const totals = useMemo(
|
||||
() => overviewTotals(data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA, gateway),
|
||||
[data.metadata, gateway],
|
||||
);
|
||||
|
||||
return {
|
||||
results,
|
||||
totals,
|
||||
loading,
|
||||
requestCountsPending: loading && gateway === null,
|
||||
failed,
|
||||
budget: currentUser?.max_budget ?? null,
|
||||
retry: () => setAttempt((n) => n + 1),
|
||||
};
|
||||
}
|
||||
|
|
@ -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) },
|
||||
);
|
||||
13
ui/litellm-dashboard/src/app/(dashboard)/home/page.tsx
Normal file
13
ui/litellm-dashboard/src/app/(dashboard)/home/page.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import LoadingScreen from "@/components/common_components/LoadingScreen";
|
||||
import HomePage from "./_components/HomePage";
|
||||
|
||||
export default function HomeRoutePage() {
|
||||
const { isLoading, isAuthorized } = useAuthorized();
|
||||
if (isLoading || !isAuthorized) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
return <HomePage />;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { uiHref } from "@/utils/uiHref";
|
|||
|
||||
const LEGACY_PAGE_ROUTES: ReadonlyMap<string, string> = new Map(
|
||||
Object.entries({
|
||||
home: "home",
|
||||
"api-keys": "api-keys",
|
||||
models: "models-and-endpoints",
|
||||
api_ref: "api-reference",
|
||||
|
|
|
|||
|
|
@ -37,8 +37,8 @@ vi.mock("@/contexts/AuthContext", () => ({
|
|||
}),
|
||||
}));
|
||||
vi.mock("@/app/(dashboard)/hooks/keys/useKeys", () => ({ useKeys: mockUseKeys }));
|
||||
vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({
|
||||
default: () => <div data-testid="api-keys-dashboard" />,
|
||||
vi.mock("@/app/(dashboard)/home/_components/HomePage", () => ({
|
||||
default: () => <div data-testid="home-page" />,
|
||||
}));
|
||||
vi.mock("@/components/common_components/LoadingScreen", () => ({
|
||||
default: () => <div data-testid="loading-screen" />,
|
||||
|
|
@ -86,7 +86,7 @@ describe("dashboard landing", () => {
|
|||
(role) => {
|
||||
state.userRole = role;
|
||||
render(<CreateKeyPage />);
|
||||
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("home-page")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("loading-screen")).not.toBeInTheDocument();
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
expect(mockUiHref).not.toHaveBeenCalledWith("connect");
|
||||
|
|
@ -96,7 +96,7 @@ describe("dashboard landing", () => {
|
|||
it("lands a user who already owns a key on the keys dashboard", () => {
|
||||
state.keys = [{ token: "sk-abc" }];
|
||||
render(<CreateKeyPage />);
|
||||
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("home-page")).toBeInTheDocument();
|
||||
expect(mockReplace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -110,7 +110,7 @@ describe("dashboard landing", () => {
|
|||
render(<CreateKeyPage />);
|
||||
expect(mockReplace).toHaveBeenCalledWith("/mocked-ui/logs");
|
||||
expect(screen.getByTestId("loading-screen")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("api-keys-dashboard")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("home-page")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("carries the MCP env-var deep link's other params through the legacy redirect", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import ApiKeysDashboard from "@/app/(dashboard)/api-keys/ApiKeysDashboard";
|
||||
import HomePage from "@/app/(dashboard)/home/_components/HomePage";
|
||||
import LoadingScreen from "@/components/common_components/LoadingScreen";
|
||||
import { proxyBaseUrl } from "@/components/networking";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
|
@ -89,7 +89,7 @@ function CreateKeyPageContent() {
|
|||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return <ApiKeysDashboard />;
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
export default function CreateKeyPage() {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
* matching colors.
|
||||
*/
|
||||
|
||||
import React, { type ReactNode, useMemo, useState } from "react";
|
||||
import { type ReactNode, useMemo, useState } from "react";
|
||||
import type { DailyData } from "@/components/UsagePage/types";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { StackedUsageChart, type StackedUsageScale } from "@/components/shared/charts";
|
||||
|
|
@ -14,18 +14,15 @@ import {
|
|||
bucketSeries,
|
||||
bucketTotals,
|
||||
labelForDate,
|
||||
dailyTotals,
|
||||
formatCompact,
|
||||
formatLatency,
|
||||
formatMetricValue,
|
||||
formatUsd,
|
||||
type Granularity,
|
||||
type OverviewTotals,
|
||||
type UsageMetric,
|
||||
} from "./overviewData";
|
||||
import { BreakdownControls, Leaderboard, useBreakdown, type BreakdownState } from "./BreakdownChart";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented, Sparkline, Stat } from "./Primitives";
|
||||
import { ChartSkeleton, Panel, PANEL_INSET_X, Segmented } from "./Primitives";
|
||||
import UsageStatStrip from "./UsageStatStrip";
|
||||
|
||||
interface UsageOverviewProps {
|
||||
results: readonly DailyData[];
|
||||
|
|
@ -40,7 +37,6 @@ interface UsageOverviewProps {
|
|||
}
|
||||
|
||||
const METRIC_NOUN: Record<UsageMetric, string> = { spend: "spend", tokens: "tokens", requests: "requests" };
|
||||
const BRAND = "#2b3fd6";
|
||||
const GRANULARITY_OPTIONS = [
|
||||
{ value: "day", label: "Daily" },
|
||||
{ value: "week", label: "Weekly" },
|
||||
|
|
@ -67,57 +63,17 @@ export default function UsageOverview({
|
|||
const { series: dailySeries, ranking } = useBreakdown(results, state, 8);
|
||||
const series = useMemo(() => bucketSeries(dailySeries, granularity), [dailySeries, granularity]);
|
||||
const totalsByBucket = useMemo(() => bucketTotals(series), [series]);
|
||||
const daily = useMemo(() => dailyTotals(results), [results]);
|
||||
const format = (value: number) => formatMetricValue(value, state.metric);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="grid grid-cols-1 overflow-hidden rounded-xl border bg-card sm:grid-cols-2 lg:grid-cols-4 lg:divide-x">
|
||||
<StatCell trend={<Sparkline data={daily} dataKey="spend" color={BRAND} className="h-14" />}>
|
||||
<Stat
|
||||
label="Spend"
|
||||
value={formatUsd(totals.spend)}
|
||||
pending={loading}
|
||||
hint={budget ? `of ${formatUsd(budget)} budget` : "No budget set"}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell trend={<Sparkline data={daily} dataKey="requests" color={BRAND} className="h-14" />}>
|
||||
<Stat label="Total Requests" value={totals.requests.toLocaleString()} pending={requestCountsPending} />
|
||||
{!requestCountsPending && (
|
||||
<div className="mt-0.5 flex gap-3 text-xs leading-4 text-muted-foreground">
|
||||
<span>
|
||||
<span className="text-success tabular-nums">{totals.successful.toLocaleString()}</span> ok
|
||||
</span>
|
||||
<span>
|
||||
<span className={totals.failed > 0 ? "text-destructive tabular-nums" : "tabular-nums"}>
|
||||
{totals.failed.toLocaleString()}
|
||||
</span>{" "}
|
||||
failed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</StatCell>
|
||||
<StatCell trend={<Sparkline data={daily} dataKey="tokens" color={BRAND} className="h-14" />}>
|
||||
<Stat
|
||||
label="Total Tokens"
|
||||
value={loading ? null : formatCompact(totals.tokens)}
|
||||
exact={`${totals.tokens.toLocaleString()} tokens`}
|
||||
pending={loading}
|
||||
hint={loading ? undefined : `${formatCompact(totals.cacheReadTokens)} cache read`}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<div className="grid gap-3">
|
||||
<Stat label="Avg cost / request" value={formatUsd(totals.avgCostPerRequest, 4)} pending={loading} />
|
||||
<Stat
|
||||
label="Avg latency"
|
||||
value={totals.avgLatencyMs === null ? "—" : formatLatency(totals.avgLatencyMs)}
|
||||
pending={loading}
|
||||
hint={totals.successRate === null ? undefined : `${totals.successRate.toFixed(1)}% success rate`}
|
||||
/>
|
||||
</div>
|
||||
</StatCell>
|
||||
</div>
|
||||
<UsageStatStrip
|
||||
results={results}
|
||||
totals={totals}
|
||||
loading={loading}
|
||||
requestCountsPending={requestCountsPending}
|
||||
budget={budget}
|
||||
/>
|
||||
|
||||
<Panel
|
||||
title="Top models"
|
||||
|
|
@ -175,17 +131,3 @@ export default function UsageOverview({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCell({ children, trend }: { children: React.ReactNode; trend?: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col justify-between gap-3 border-b py-4 last:border-b-0 lg:border-b-0",
|
||||
PANEL_INSET_X,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">{children}</div>
|
||||
{trend}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import type { DailyData } from "@/components/UsagePage/types";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { dailyTotals, formatCompact, formatLatency, formatUsd, type OverviewTotals } from "./overviewData";
|
||||
import { PANEL_INSET_X, Sparkline, Stat } from "./Primitives";
|
||||
|
||||
const BRAND = "#2b3fd6";
|
||||
|
||||
interface UsageStatStripProps {
|
||||
results: readonly DailyData[];
|
||||
totals: OverviewTotals;
|
||||
loading: boolean;
|
||||
requestCountsPending: boolean;
|
||||
budget: number | null;
|
||||
}
|
||||
|
||||
/** Spend, requests, tokens and averages for a range, each with a daily sparkline. */
|
||||
export default function UsageStatStrip({
|
||||
results,
|
||||
totals,
|
||||
loading,
|
||||
requestCountsPending,
|
||||
budget,
|
||||
}: UsageStatStripProps) {
|
||||
const daily = useMemo(() => dailyTotals(results), [results]);
|
||||
return (
|
||||
<div className="grid grid-cols-1 overflow-hidden rounded-xl border bg-card sm:grid-cols-2 lg:grid-cols-4 lg:divide-x">
|
||||
<StatCell trend={<Sparkline data={daily} dataKey="spend" color={BRAND} className="h-14" />}>
|
||||
<Stat
|
||||
label="Spend"
|
||||
value={formatUsd(totals.spend)}
|
||||
pending={loading}
|
||||
hint={budget ? `of ${formatUsd(budget)} budget` : "No budget set"}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell trend={<Sparkline data={daily} dataKey="requests" color={BRAND} className="h-14" />}>
|
||||
<Stat label="Total Requests" value={totals.requests.toLocaleString()} pending={requestCountsPending} />
|
||||
{!requestCountsPending && (
|
||||
<div className="mt-0.5 flex gap-3 text-xs leading-4 text-muted-foreground">
|
||||
<span>
|
||||
<span className="text-success tabular-nums">{totals.successful.toLocaleString()}</span> ok
|
||||
</span>
|
||||
<span>
|
||||
<span className={totals.failed > 0 ? "text-destructive tabular-nums" : "tabular-nums"}>
|
||||
{totals.failed.toLocaleString()}
|
||||
</span>{" "}
|
||||
failed
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</StatCell>
|
||||
<StatCell trend={<Sparkline data={daily} dataKey="tokens" color={BRAND} className="h-14" />}>
|
||||
<Stat
|
||||
label="Total Tokens"
|
||||
value={loading ? null : formatCompact(totals.tokens)}
|
||||
exact={`${totals.tokens.toLocaleString()} tokens`}
|
||||
pending={loading}
|
||||
hint={loading ? undefined : `${formatCompact(totals.cacheReadTokens)} cache read`}
|
||||
/>
|
||||
</StatCell>
|
||||
<StatCell>
|
||||
<div className="grid gap-3">
|
||||
<Stat label="Avg cost / request" value={formatUsd(totals.avgCostPerRequest, 4)} pending={loading} />
|
||||
<Stat
|
||||
label="Avg latency"
|
||||
value={totals.avgLatencyMs === null ? "—" : formatLatency(totals.avgLatencyMs)}
|
||||
pending={loading}
|
||||
hint={totals.successRate === null ? undefined : `${totals.successRate.toFixed(1)}% success rate`}
|
||||
/>
|
||||
</div>
|
||||
</StatCell>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCell({ children, trend }: { children: React.ReactNode; trend?: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-col justify-between gap-3 border-b py-4 last:border-b-0 lg:border-b-0",
|
||||
PANEL_INSET_X,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">{children}</div>
|
||||
{trend}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@ import { flattenNavItems, matchNavItems, paletteScopeForRoute, shortcutLabel, ty
|
|||
describe("paletteScopeForRoute", () => {
|
||||
it.each([
|
||||
["api-keys", "keys"],
|
||||
["", "keys"],
|
||||
["", "global"],
|
||||
["teams", "global"],
|
||||
] as const)("uses the expected palette scope for %s", (routeSegment, expected) => {
|
||||
expect(paletteScopeForRoute(routeSegment)).toBe(expected);
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export interface PaletteNavItem {
|
|||
}
|
||||
|
||||
export const paletteScopeForRoute = (routeSegment: string): PaletteScope =>
|
||||
routeSegment === "" || routeSegment === "api-keys" ? "keys" : "global";
|
||||
routeSegment === "api-keys" ? "keys" : "global";
|
||||
|
||||
const matchRank = (item: PaletteNavItem, query: string): number => {
|
||||
const label = item.label.toLowerCase();
|
||||
|
|
|
|||
|
|
@ -59,11 +59,11 @@ describe("DashboardHeader breadcrumb", () => {
|
|||
expect(screen.getByText("Models + Endpoints")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("titles the dashboard root as Virtual Keys", () => {
|
||||
it("titles the dashboard root as Home", () => {
|
||||
state.pathname = "/ui/";
|
||||
renderDashboardHeader();
|
||||
|
||||
expect(screen.getByText("Virtual Keys")).toBeInTheDocument();
|
||||
expect(screen.getByText("Home")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("roots the breadcrumb in the AI Gateway selector (with a Chat option) and drops the static section crumb when the selector is available", async () => {
|
||||
|
|
|
|||
|
|
@ -247,6 +247,7 @@ describe("Sidebar (leftnav)", () => {
|
|||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
const topLevelLabels = [
|
||||
"Home",
|
||||
"Virtual Keys",
|
||||
"Playground",
|
||||
"Models + Endpoints",
|
||||
|
|
@ -660,10 +661,10 @@ describe("Sidebar (leftnav)", () => {
|
|||
expect(screen.getByRole("link", { name: "Virtual Keys" })).not.toHaveAttribute("data-active");
|
||||
});
|
||||
|
||||
it("marks Virtual Keys active at the dashboard root", () => {
|
||||
it("marks Home active at the dashboard root", () => {
|
||||
navState.pathname = "/ui/";
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
expect(screen.getByRole("link", { name: "Virtual Keys" })).toHaveAttribute("data-active", "true");
|
||||
expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute("data-active", "true");
|
||||
});
|
||||
|
||||
it("expands the parent group of the current nested route and marks the child active", () => {
|
||||
|
|
@ -745,8 +746,8 @@ describe("getBreadcrumb", () => {
|
|||
expect(getBreadcrumb("/ui/old-usage")).toEqual({ section: "Developer Tools", title: "Old Usage" });
|
||||
});
|
||||
|
||||
it("titles the dashboard root as Virtual Keys", () => {
|
||||
expect(getBreadcrumb("/ui/")).toEqual({ section: "AI Gateway", title: "Virtual Keys" });
|
||||
it("titles the dashboard root as Home", () => {
|
||||
expect(getBreadcrumb("/ui/")).toEqual({ section: "AI Gateway", title: "Home" });
|
||||
});
|
||||
|
||||
it("resolves a nested child route to its parent section", () => {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import {
|
|||
FlaskConical,
|
||||
Folder,
|
||||
HeartPulse,
|
||||
House,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
|
|
@ -207,6 +208,7 @@ const menuGroups: MenuGroup[] = [
|
|||
{
|
||||
groupLabel: "AI GATEWAY",
|
||||
items: [
|
||||
{ key: "home", page: "home", label: "Home", icon: <House {...ICON} /> },
|
||||
{ key: "api-keys", page: "api-keys", label: "Virtual Keys", icon: <KeyRound {...ICON} /> },
|
||||
{
|
||||
key: "llm-playground",
|
||||
|
|
@ -468,7 +470,7 @@ const menuGroups: MenuGroup[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const HOME_ROUTE = "api-keys";
|
||||
const HOME_ROUTE = "home";
|
||||
|
||||
export const routeOf = (item: MenuItem): string => item.route ?? item.page;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
// Page descriptions for UI Settings configuration
|
||||
export const pageDescriptions: Record<string, string> = {
|
||||
home: "Usage at a glance, recent launches and blog updates",
|
||||
"api-keys": "Manage virtual keys for API access and authentication",
|
||||
"llm-playground": "Interactive playground for testing LLM requests",
|
||||
models: "Configure and manage LLM models and endpoints",
|
||||
|
|
|
|||
69
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
69
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -14152,6 +14152,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;
|
||||
|
|
@ -51031,6 +51056,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"][];
|
||||
};
|
||||
/** Worker */
|
||||
Worker: {
|
||||
/** Analysis Key Id */
|
||||
|
|
@ -71403,6 +71452,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?: {
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ vi.mock("@/utils/returnUrlUtils", async (importOriginal) => {
|
|||
|
||||
// Super-light stubs for all heavy components so rendering doesn't explode
|
||||
vi.mock("@/components/navbar", () => ({ default: stub("navbar") }));
|
||||
vi.mock("@/app/(dashboard)/api-keys/ApiKeysDashboard", () => ({ default: stub("api-keys-dashboard") }));
|
||||
vi.mock("@/app/(dashboard)/home/_components/HomePage", () => ({ default: stub("home-page") }));
|
||||
vi.mock("@/components/templates/model_dashboard", () => ({ default: stub("model-dashboard") }));
|
||||
vi.mock("@/components/teams", () => ({ default: stub("teams") }));
|
||||
vi.mock("@/app/(dashboard)/organizations/_components/organizations", () => ({
|
||||
|
|
@ -271,7 +271,7 @@ describe("CreateKeyPage auth behavior", () => {
|
|||
|
||||
// And the default page content appears (ApiKeysDashboard stub; chrome now lives in the layout)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("api-keys-dashboard")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("home-page")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue