From e37d54e95be32e6b2432898c9ab7449dd805d55c Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 16:41:21 -0800 Subject: [PATCH 01/18] feat: add blog_posts.json and local backup --- blog_posts.json | 16 ++++++++++++++++ litellm/blog_posts_backup.json | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 blog_posts.json create mode 100644 litellm/blog_posts_backup.json diff --git a/blog_posts.json b/blog_posts.json new file mode 100644 index 000000000000..8e29e3c4e196 --- /dev/null +++ b/blog_posts.json @@ -0,0 +1,16 @@ +{ + "posts": [ + { + "title": "LiteLLM: Unified Interface for 100+ LLMs", + "description": "Learn how LiteLLM provides a single interface to call any LLM with OpenAI-compatible syntax.", + "date": "2026-02-01", + "url": "https://www.litellm.ai/blog/litellm" + }, + { + "title": "Using the LiteLLM Proxy for Load Balancing", + "description": "Set up the LiteLLM proxy server to load balance across multiple LLM providers and deployments.", + "date": "2026-01-15", + "url": "https://www.litellm.ai/blog/proxy-load-balancing" + } + ] +} diff --git a/litellm/blog_posts_backup.json b/litellm/blog_posts_backup.json new file mode 100644 index 000000000000..8e29e3c4e196 --- /dev/null +++ b/litellm/blog_posts_backup.json @@ -0,0 +1,16 @@ +{ + "posts": [ + { + "title": "LiteLLM: Unified Interface for 100+ LLMs", + "description": "Learn how LiteLLM provides a single interface to call any LLM with OpenAI-compatible syntax.", + "date": "2026-02-01", + "url": "https://www.litellm.ai/blog/litellm" + }, + { + "title": "Using the LiteLLM Proxy for Load Balancing", + "description": "Set up the LiteLLM proxy server to load balance across multiple LLM providers and deployments.", + "date": "2026-01-15", + "url": "https://www.litellm.ai/blog/proxy-load-balancing" + } + ] +} From be1a543b55e05f01c1f554922dfe0452c7ef1283 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 16:44:33 -0800 Subject: [PATCH 02/18] feat: add GetBlogPosts utility with GitHub fetch and local fallback Adds GetBlogPosts class that fetches blog posts from GitHub with a 1-hour in-process TTL cache, validates the response, and falls back to the bundled blog_posts_backup.json on any network or validation failure. --- litellm/litellm_core_utils/get_blog_posts.py | 134 ++++++++++++++++ tests/test_litellm/test_get_blog_posts.py | 157 +++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 litellm/litellm_core_utils/get_blog_posts.py create mode 100644 tests/test_litellm/test_get_blog_posts.py diff --git a/litellm/litellm_core_utils/get_blog_posts.py b/litellm/litellm_core_utils/get_blog_posts.py new file mode 100644 index 000000000000..70fbe7129b54 --- /dev/null +++ b/litellm/litellm_core_utils/get_blog_posts.py @@ -0,0 +1,134 @@ +""" +Pulls the latest LiteLLM blog posts from GitHub. + +Falls back to the bundled local backup on any failure. +GitHub JSON can be overridden via LITELLM_BLOG_POSTS_URL env var. + +Disable remote fetching entirely: + export LITELLM_LOCAL_BLOG_POSTS=True +""" + +import json +import os +import time +from importlib.resources import files +from typing import Any, Dict, List, Optional + +import httpx +from pydantic import BaseModel + +from litellm import verbose_logger + +BLOG_POSTS_GITHUB_URL: str = os.getenv( + "LITELLM_BLOG_POSTS_URL", + "https://raw.githubusercontent.com/BerriAI/litellm/main/blog_posts.json", +) + +BLOG_POSTS_TTL_SECONDS: int = 3600 # 1 hour + + +class BlogPost(BaseModel): + title: str + description: str + date: str + url: str + + +class BlogPostsResponse(BaseModel): + posts: List[BlogPost] + + +class GetBlogPosts: + """ + Fetches, validates, and caches LiteLLM blog posts. + + Mirrors the structure of GetModelCostMap: + - Fetches from GitHub with a 5-second timeout + - Validates the response has a non-empty ``posts`` list + - Caches the result in-process for BLOG_POSTS_TTL_SECONDS (1 hour) + - Falls back to the bundled local backup on any failure + """ + + _cached_posts: Optional[List[Dict[str, str]]] = None + _last_fetch_time: float = 0.0 + + @staticmethod + def load_local_blog_posts() -> List[Dict[str, str]]: + """Load the bundled local backup blog posts.""" + content = json.loads( + files("litellm") + .joinpath("blog_posts_backup.json") + .read_text(encoding="utf-8") + ) + return content.get("posts", []) + + @staticmethod + def fetch_remote_blog_posts(url: str, timeout: int = 5) -> Any: + """ + Fetch blog posts JSON from a remote URL. + + Returns the parsed response. Raises on network/parse errors. + """ + response = httpx.get(url, timeout=timeout) + response.raise_for_status() + return response.json() + + @staticmethod + def validate_blog_posts(data: Any) -> bool: + """Return True if data is a dict with a non-empty ``posts`` list.""" + if not isinstance(data, dict): + verbose_logger.warning( + "LiteLLM: Blog posts response is not a dict (type=%s). " + "Falling back to local backup.", + type(data).__name__, + ) + return False + posts = data.get("posts") + if not isinstance(posts, list) or len(posts) == 0: + verbose_logger.warning( + "LiteLLM: Blog posts response has no valid 'posts' list. " + "Falling back to local backup.", + ) + return False + return True + + @classmethod + def get_blog_posts(cls, url: str = BLOG_POSTS_GITHUB_URL) -> List[Dict[str, str]]: + """ + Return the blog posts list. + + Uses the in-process cache if within BLOG_POSTS_TTL_SECONDS. + Fetches from ``url`` otherwise, falling back to local backup on failure. + """ + if os.getenv("LITELLM_LOCAL_BLOG_POSTS", "").lower() == "true": + return cls.load_local_blog_posts() + + now = time.time() + if ( + cls._cached_posts is not None + and (now - cls._last_fetch_time) < BLOG_POSTS_TTL_SECONDS + ): + return cls._cached_posts + + try: + data = cls.fetch_remote_blog_posts(url) + except Exception as e: + verbose_logger.warning( + "LiteLLM: Failed to fetch blog posts from %s: %s. " + "Falling back to local backup.", + url, + str(e), + ) + return cls.load_local_blog_posts() + + if not cls.validate_blog_posts(data): + return cls.load_local_blog_posts() + + cls._cached_posts = data["posts"] + cls._last_fetch_time = now + return cls._cached_posts + + +def get_blog_posts(url: str = BLOG_POSTS_GITHUB_URL) -> List[Dict[str, str]]: + """Public entry point — returns the blog posts list.""" + return GetBlogPosts.get_blog_posts(url=url) diff --git a/tests/test_litellm/test_get_blog_posts.py b/tests/test_litellm/test_get_blog_posts.py new file mode 100644 index 000000000000..29cecfaf5d5a --- /dev/null +++ b/tests/test_litellm/test_get_blog_posts.py @@ -0,0 +1,157 @@ +"""Tests for GetBlogPosts utility class.""" +import json +import time +from unittest.mock import MagicMock, patch + +import pytest + +from litellm.litellm_core_utils.get_blog_posts import ( + BLOG_POSTS_GITHUB_URL, + BlogPost, + BlogPostsResponse, + GetBlogPosts, + get_blog_posts, +) + +SAMPLE_RESPONSE = { + "posts": [ + { + "title": "Test Post", + "description": "A test post.", + "date": "2026-01-01", + "url": "https://www.litellm.ai/blog/test", + } + ] +} + + +def test_load_local_blog_posts_returns_list(): + posts = GetBlogPosts.load_local_blog_posts() + assert isinstance(posts, list) + assert len(posts) > 0 + first = posts[0] + assert "title" in first + assert "description" in first + assert "date" in first + assert "url" in first + + +def test_validate_blog_posts_valid(): + assert GetBlogPosts.validate_blog_posts(SAMPLE_RESPONSE) is True + + +def test_validate_blog_posts_missing_posts_key(): + assert GetBlogPosts.validate_blog_posts({"other": []}) is False + + +def test_validate_blog_posts_empty_list(): + assert GetBlogPosts.validate_blog_posts({"posts": []}) is False + + +def test_validate_blog_posts_not_dict(): + assert GetBlogPosts.validate_blog_posts("not a dict") is False + + +def test_get_blog_posts_success(monkeypatch): + """Fetches from remote on first call.""" + # Reset class cache state + GetBlogPosts._cached_posts = None + GetBlogPosts._last_fetch_time = 0.0 + + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_RESPONSE + mock_response.raise_for_status = MagicMock() + + with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response): + posts = get_blog_posts() + + assert len(posts) == 1 + assert posts[0]["title"] == "Test Post" + + +def test_get_blog_posts_network_error_falls_back_to_local(monkeypatch): + """Falls back to local backup on network error.""" + GetBlogPosts._cached_posts = None + GetBlogPosts._last_fetch_time = 0.0 + + with patch( + "litellm.litellm_core_utils.get_blog_posts.httpx.get", + side_effect=Exception("Network error"), + ): + posts = get_blog_posts() + + assert isinstance(posts, list) + assert len(posts) > 0 + + +def test_get_blog_posts_invalid_json_falls_back_to_local(monkeypatch): + """Falls back when remote returns non-dict.""" + GetBlogPosts._cached_posts = None + GetBlogPosts._last_fetch_time = 0.0 + + mock_response = MagicMock() + mock_response.json.return_value = "not a dict" + mock_response.raise_for_status = MagicMock() + + with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response): + posts = get_blog_posts() + + assert isinstance(posts, list) + assert len(posts) > 0 + + +def test_get_blog_posts_ttl_cache_not_refetched(monkeypatch): + """Within TTL window, does not re-fetch.""" + GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"] + GetBlogPosts._last_fetch_time = time.time() # just now + + call_count = 0 + + def mock_get(*args, **kwargs): + nonlocal call_count + call_count += 1 + m = MagicMock() + m.json.return_value = SAMPLE_RESPONSE + m.raise_for_status = MagicMock() + return m + + with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=mock_get): + posts = get_blog_posts() + + assert call_count == 0 # cache hit, no fetch + assert len(posts) == 1 + + +def test_get_blog_posts_ttl_expired_refetches(monkeypatch): + """After TTL window, re-fetches from remote.""" + GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"] + GetBlogPosts._last_fetch_time = time.time() - 7200 # 2 hours ago + + mock_response = MagicMock() + mock_response.json.return_value = SAMPLE_RESPONSE + mock_response.raise_for_status = MagicMock() + + with patch( + "litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response + ) as mock_get: + posts = get_blog_posts() + + mock_get.assert_called_once() + assert len(posts) == 1 + + +def test_blog_post_pydantic_model(): + post = BlogPost( + title="T", + description="D", + date="2026-01-01", + url="https://example.com", + ) + assert post.title == "T" + + +def test_blog_posts_response_pydantic_model(): + resp = BlogPostsResponse( + posts=[BlogPost(title="T", description="D", date="2026-01-01", url="https://x.com")] + ) + assert len(resp.posts) == 1 From 7e5c63c13487162151bfbf9f0e9911341b305a4b Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 16:48:54 -0800 Subject: [PATCH 03/18] test: add cache reset fixture and LITELLM_LOCAL_BLOG_POSTS test Co-Authored-By: Claude Sonnet 4.6 --- litellm/litellm_core_utils/get_blog_posts.py | 2 +- tests/test_litellm/test_get_blog_posts.py | 38 ++++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/litellm/litellm_core_utils/get_blog_posts.py b/litellm/litellm_core_utils/get_blog_posts.py index 70fbe7129b54..137e32b0c9e6 100644 --- a/litellm/litellm_core_utils/get_blog_posts.py +++ b/litellm/litellm_core_utils/get_blog_posts.py @@ -63,7 +63,7 @@ def load_local_blog_posts() -> List[Dict[str, str]]: return content.get("posts", []) @staticmethod - def fetch_remote_blog_posts(url: str, timeout: int = 5) -> Any: + def fetch_remote_blog_posts(url: str, timeout: int = 5) -> dict: """ Fetch blog posts JSON from a remote URL. diff --git a/tests/test_litellm/test_get_blog_posts.py b/tests/test_litellm/test_get_blog_posts.py index 29cecfaf5d5a..3f36a5b5fa54 100644 --- a/tests/test_litellm/test_get_blog_posts.py +++ b/tests/test_litellm/test_get_blog_posts.py @@ -25,6 +25,15 @@ } +@pytest.fixture(autouse=True) +def reset_blog_posts_cache(): + GetBlogPosts._cached_posts = None + GetBlogPosts._last_fetch_time = 0.0 + yield + GetBlogPosts._cached_posts = None + GetBlogPosts._last_fetch_time = 0.0 + + def test_load_local_blog_posts_returns_list(): posts = GetBlogPosts.load_local_blog_posts() assert isinstance(posts, list) @@ -52,12 +61,8 @@ def test_validate_blog_posts_not_dict(): assert GetBlogPosts.validate_blog_posts("not a dict") is False -def test_get_blog_posts_success(monkeypatch): +def test_get_blog_posts_success(): """Fetches from remote on first call.""" - # Reset class cache state - GetBlogPosts._cached_posts = None - GetBlogPosts._last_fetch_time = 0.0 - mock_response = MagicMock() mock_response.json.return_value = SAMPLE_RESPONSE mock_response.raise_for_status = MagicMock() @@ -69,11 +74,8 @@ def test_get_blog_posts_success(monkeypatch): assert posts[0]["title"] == "Test Post" -def test_get_blog_posts_network_error_falls_back_to_local(monkeypatch): +def test_get_blog_posts_network_error_falls_back_to_local(): """Falls back to local backup on network error.""" - GetBlogPosts._cached_posts = None - GetBlogPosts._last_fetch_time = 0.0 - with patch( "litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=Exception("Network error"), @@ -84,11 +86,8 @@ def test_get_blog_posts_network_error_falls_back_to_local(monkeypatch): assert len(posts) > 0 -def test_get_blog_posts_invalid_json_falls_back_to_local(monkeypatch): +def test_get_blog_posts_invalid_json_falls_back_to_local(): """Falls back when remote returns non-dict.""" - GetBlogPosts._cached_posts = None - GetBlogPosts._last_fetch_time = 0.0 - mock_response = MagicMock() mock_response.json.return_value = "not a dict" mock_response.raise_for_status = MagicMock() @@ -100,7 +99,7 @@ def test_get_blog_posts_invalid_json_falls_back_to_local(monkeypatch): assert len(posts) > 0 -def test_get_blog_posts_ttl_cache_not_refetched(monkeypatch): +def test_get_blog_posts_ttl_cache_not_refetched(): """Within TTL window, does not re-fetch.""" GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"] GetBlogPosts._last_fetch_time = time.time() # just now @@ -122,7 +121,7 @@ def mock_get(*args, **kwargs): assert len(posts) == 1 -def test_get_blog_posts_ttl_expired_refetches(monkeypatch): +def test_get_blog_posts_ttl_expired_refetches(): """After TTL window, re-fetches from remote.""" GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"] GetBlogPosts._last_fetch_time = time.time() - 7200 # 2 hours ago @@ -140,6 +139,15 @@ def test_get_blog_posts_ttl_expired_refetches(monkeypatch): assert len(posts) == 1 +def test_get_blog_posts_local_env_var_skips_remote(monkeypatch): + monkeypatch.setenv("LITELLM_LOCAL_BLOG_POSTS", "true") + with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get") as mock_get: + posts = get_blog_posts() + mock_get.assert_not_called() + assert isinstance(posts, list) + assert len(posts) > 0 + + def test_blog_post_pydantic_model(): post = BlogPost( title="T", From 021a9097c4f1f5ae22de4b40dd69d7cdf453678a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 16:51:55 -0800 Subject: [PATCH 04/18] feat: add GET /public/litellm_blog_posts endpoint Co-Authored-By: Claude Sonnet 4.6 --- .../public_endpoints/public_endpoints.py | 27 +++++++ .../test_blog_posts_endpoint.py | 79 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 tests/proxy_unit_tests/test_blog_posts_endpoint.py diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 6d60a218fd18..f5bca5318396 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -4,6 +4,12 @@ from fastapi import APIRouter, Depends, HTTPException +from litellm.litellm_core_utils.get_blog_posts import ( + BlogPost, + BlogPostsResponse, + GetBlogPosts, + get_blog_posts, +) from litellm.proxy._types import CommonProxyErrors from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.types.agents import AgentCard @@ -193,6 +199,27 @@ async def get_litellm_model_cost_map(): ) +@router.get( + "/public/litellm_blog_posts", + tags=["public"], + response_model=BlogPostsResponse, +) +async def get_litellm_blog_posts(): + """ + Public endpoint to get the latest LiteLLM blog posts. + + Fetches from GitHub with a 1-hour in-process cache. + Falls back to the bundled local backup on any failure. + """ + try: + posts_data = get_blog_posts() + except Exception: + posts_data = GetBlogPosts.load_local_blog_posts() + + posts = [BlogPost(**p) for p in posts_data[:5]] + return BlogPostsResponse(posts=posts) + + @router.get( "/public/agents/fields", tags=["public", "[beta] Agents"], diff --git a/tests/proxy_unit_tests/test_blog_posts_endpoint.py b/tests/proxy_unit_tests/test_blog_posts_endpoint.py new file mode 100644 index 000000000000..1c5db4d2cfec --- /dev/null +++ b/tests/proxy_unit_tests/test_blog_posts_endpoint.py @@ -0,0 +1,79 @@ +"""Tests for the /public/litellm_blog_posts endpoint.""" +from unittest.mock import patch + +import pytest +from fastapi.testclient import TestClient + +SAMPLE_POSTS = [ + { + "title": "Test Post", + "description": "A test post.", + "date": "2026-01-01", + "url": "https://www.litellm.ai/blog/test", + } +] + + +@pytest.fixture +def client(): + """Create a TestClient with just the public_endpoints router.""" + from fastapi import FastAPI + + from litellm.proxy.public_endpoints.public_endpoints import router + + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def test_get_blog_posts_returns_response_shape(client): + with patch( + "litellm.proxy.public_endpoints.public_endpoints.get_blog_posts", + return_value=SAMPLE_POSTS, + ): + response = client.get("/public/litellm_blog_posts") + + assert response.status_code == 200 + data = response.json() + assert "posts" in data + assert len(data["posts"]) == 1 + post = data["posts"][0] + assert post["title"] == "Test Post" + assert post["description"] == "A test post." + assert post["date"] == "2026-01-01" + assert post["url"] == "https://www.litellm.ai/blog/test" + + +def test_get_blog_posts_limits_to_five(client): + """Endpoint returns at most 5 posts.""" + many_posts = [ + { + "title": f"Post {i}", + "description": "desc", + "date": "2026-01-01", + "url": f"https://www.litellm.ai/blog/{i}", + } + for i in range(10) + ] + + with patch( + "litellm.proxy.public_endpoints.public_endpoints.get_blog_posts", + return_value=many_posts, + ): + response = client.get("/public/litellm_blog_posts") + + assert response.status_code == 200 + assert len(response.json()["posts"]) == 5 + + +def test_get_blog_posts_returns_local_backup_on_failure(client): + """Endpoint returns local backup (non-empty list) when fetcher fails.""" + with patch( + "litellm.proxy.public_endpoints.public_endpoints.get_blog_posts", + side_effect=Exception("fetch failed"), + ): + response = client.get("/public/litellm_blog_posts") + + # Should not 500 — returns local backup + assert response.status_code == 200 + assert "posts" in response.json() From 4b1ce1ff54acf9e483d3390644f316c2faa10d80 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 16:54:48 -0800 Subject: [PATCH 05/18] fix: log fallback warning in blog posts endpoint and tighten test --- litellm/proxy/public_endpoints/public_endpoints.py | 6 +++++- tests/proxy_unit_tests/test_blog_posts_endpoint.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index f5bca5318396..b2fb3dd0f8a4 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException +from litellm._logging import verbose_logger from litellm.litellm_core_utils.get_blog_posts import ( BlogPost, BlogPostsResponse, @@ -213,7 +214,10 @@ async def get_litellm_blog_posts(): """ try: posts_data = get_blog_posts() - except Exception: + except Exception as e: + verbose_logger.warning( + "LiteLLM: get_litellm_blog_posts endpoint fallback triggered: %s", str(e) + ) posts_data = GetBlogPosts.load_local_blog_posts() posts = [BlogPost(**p) for p in posts_data[:5]] diff --git a/tests/proxy_unit_tests/test_blog_posts_endpoint.py b/tests/proxy_unit_tests/test_blog_posts_endpoint.py index 1c5db4d2cfec..0f93f6f80cfe 100644 --- a/tests/proxy_unit_tests/test_blog_posts_endpoint.py +++ b/tests/proxy_unit_tests/test_blog_posts_endpoint.py @@ -77,3 +77,4 @@ def test_get_blog_posts_returns_local_backup_on_failure(client): # Should not 500 — returns local backup assert response.status_code == 200 assert "posts" in response.json() + assert len(response.json()["posts"]) > 0 From ca9111ea318c696d79a62663efa1c5fbca0fb47d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 16:56:30 -0800 Subject: [PATCH 06/18] feat: add disable_show_blog to UISettings Co-Authored-By: Claude Sonnet 4.6 --- .../ui_crud_endpoints/proxy_setting_endpoints.py | 6 ++++++ .../test_proxy_setting_endpoints.py | 13 +++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 tests/proxy_unit_tests/test_proxy_setting_endpoints.py diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index b465d13bc701..f6795f12bbc5 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -93,6 +93,11 @@ class UISettings(BaseModel): description="If enabled, forwards client headers (e.g. Authorization) to the LLM API. Required for Claude Code with Max subscription.", ) + disable_show_blog: bool = Field( + default=False, + description="If true, hides the Blog dropdown from the UI navbar.", + ) + class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -107,6 +112,7 @@ class UISettingsResponse(SettingsResponse): "enabled_ui_pages_internal_users", "require_auth_for_public_ai_hub", "forward_client_headers_to_llm_api", + "disable_show_blog", } diff --git a/tests/proxy_unit_tests/test_proxy_setting_endpoints.py b/tests/proxy_unit_tests/test_proxy_setting_endpoints.py new file mode 100644 index 000000000000..1bc509196898 --- /dev/null +++ b/tests/proxy_unit_tests/test_proxy_setting_endpoints.py @@ -0,0 +1,13 @@ +def test_ui_settings_has_disable_show_blog_field(): + """UISettings model must include disable_show_blog.""" + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import UISettings + + settings = UISettings() + assert hasattr(settings, "disable_show_blog") + assert settings.disable_show_blog is False # default + + +def test_allowed_ui_settings_fields_contains_disable_show_blog(): + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ALLOWED_UI_SETTINGS_FIELDS + + assert "disable_show_blog" in ALLOWED_UI_SETTINGS_FIELDS From 70f8e979a02d9dce8c1ee16cc4faf150e200d0d9 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 16:59:34 -0800 Subject: [PATCH 07/18] feat: add useUISettings and useDisableShowBlog hooks --- .../(dashboard)/hooks/useDisableShowBlog.ts | 6 +++++ .../app/(dashboard)/hooks/useUISettings.ts | 27 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettings.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts new file mode 100644 index 000000000000..18532cd4c26b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts @@ -0,0 +1,6 @@ +import { useUISettings } from "./useUISettings"; + +export function useDisableShowBlog(): boolean { + const { data } = useUISettings(); + return data?.disable_show_blog ?? false; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettings.ts new file mode 100644 index 000000000000..f99491d59640 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettings.ts @@ -0,0 +1,27 @@ +import { getUiSettings } from "@/components/networking"; +import { useQuery } from "@tanstack/react-query"; + +export interface UISettingsData { + disable_model_add_for_internal_users: boolean; + disable_team_admin_delete_team_user: boolean; + enabled_ui_pages_internal_users: string[] | null; + require_auth_for_public_ai_hub: boolean; + forward_client_headers_to_llm_api: boolean; + disable_show_blog: boolean; +} + +async function fetchUISettings(): Promise { + // getUiSettings returns the raw response: { values: {...}, field_schema: {...} } + const data = await getUiSettings(); + return data.values as UISettingsData; +} + +export function useUISettings() { + return useQuery({ + queryKey: ["uiSettings"], + queryFn: fetchUISettings, + staleTime: 5 * 60 * 1000, // 5 minutes + gcTime: 10 * 60 * 1000, + retry: 1, + }); +} From a82c8c18b2ba5bd0168576b2844e490df2de5a2c Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 17:01:05 -0800 Subject: [PATCH 08/18] fix: rename useUISettings to useUISettingsFlags to avoid naming collision --- .../src/app/(dashboard)/hooks/useDisableShowBlog.ts | 4 ++-- .../hooks/{useUISettings.ts => useUISettingsFlags.ts} | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename ui/litellm-dashboard/src/app/(dashboard)/hooks/{useUISettings.ts => useUISettingsFlags.ts} (91%) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts index 18532cd4c26b..a31040542b8c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts @@ -1,6 +1,6 @@ -import { useUISettings } from "./useUISettings"; +import { useUISettingsFlags } from "./useUISettingsFlags"; export function useDisableShowBlog(): boolean { - const { data } = useUISettings(); + const { data } = useUISettingsFlags(); return data?.disable_show_blog ?? false; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettingsFlags.ts similarity index 91% rename from ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettings.ts rename to ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettingsFlags.ts index f99491d59640..98b3e2cc128a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettingsFlags.ts @@ -16,9 +16,9 @@ async function fetchUISettings(): Promise { return data.values as UISettingsData; } -export function useUISettings() { +export function useUISettingsFlags() { return useQuery({ - queryKey: ["uiSettings"], + queryKey: ["uiSettingsFlags"], queryFn: fetchUISettings, staleTime: 5 * 60 * 1000, // 5 minutes gcTime: 10 * 60 * 1000, From 98da524a9f255141eee05347484aac37311e4eda Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 17:04:05 -0800 Subject: [PATCH 09/18] fix: use existing useUISettings hook in useDisableShowBlog to avoid cache duplication Co-Authored-By: Claude Sonnet 4.6 --- .../(dashboard)/hooks/useDisableShowBlog.ts | 6 ++--- .../(dashboard)/hooks/useUISettingsFlags.ts | 27 ------------------- 2 files changed, 3 insertions(+), 30 deletions(-) delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettingsFlags.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts index a31040542b8c..cbe1bdc12501 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts @@ -1,6 +1,6 @@ -import { useUISettingsFlags } from "./useUISettingsFlags"; +import { useUISettings } from "./uiSettings/useUISettings"; export function useDisableShowBlog(): boolean { - const { data } = useUISettingsFlags(); - return data?.disable_show_blog ?? false; + const { data } = useUISettings(); + return (data?.values?.disable_show_blog as boolean) ?? false; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettingsFlags.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettingsFlags.ts deleted file mode 100644 index 98b3e2cc128a..000000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useUISettingsFlags.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { getUiSettings } from "@/components/networking"; -import { useQuery } from "@tanstack/react-query"; - -export interface UISettingsData { - disable_model_add_for_internal_users: boolean; - disable_team_admin_delete_team_user: boolean; - enabled_ui_pages_internal_users: string[] | null; - require_auth_for_public_ai_hub: boolean; - forward_client_headers_to_llm_api: boolean; - disable_show_blog: boolean; -} - -async function fetchUISettings(): Promise { - // getUiSettings returns the raw response: { values: {...}, field_schema: {...} } - const data = await getUiSettings(); - return data.values as UISettingsData; -} - -export function useUISettingsFlags() { - return useQuery({ - queryKey: ["uiSettingsFlags"], - queryFn: fetchUISettings, - staleTime: 5 * 60 * 1000, // 5 minutes - gcTime: 10 * 60 * 1000, - retry: 1, - }); -} From e241e6fd45cf83f24c77aa34f7e757347b8b75ac Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 17:10:25 -0800 Subject: [PATCH 10/18] feat: add BlogDropdown component with react-query and error/retry state Co-Authored-By: Claude Sonnet 4.6 --- .../Navbar/BlogDropdown/BlogDropdown.tsx | 145 ++++++++++++++++++ .../__tests__/BlogDropdown.test.tsx | 113 ++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx create mode 100644 ui/litellm-dashboard/src/components/Navbar/BlogDropdown/__tests__/BlogDropdown.test.tsx diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx new file mode 100644 index 000000000000..6f8b29f8805d --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -0,0 +1,145 @@ +import { useDisableShowBlog } from "@/app/(dashboard)/hooks/useDisableShowBlog"; +import { getProxyBaseUrl } from "@/components/networking"; +import { + DownOutlined, + LoadingOutlined, + ReadOutlined, +} from "@ant-design/icons"; +import { useQuery } from "@tanstack/react-query"; +import { Button, Dropdown, Space, Typography } from "antd"; +import React from "react"; + +const { Text } = Typography; + +interface BlogPost { + title: string; + description: string; + date: string; + url: string; +} + +interface BlogPostsResponse { + posts: BlogPost[]; +} + +async function fetchBlogPosts(): Promise { + const baseUrl = getProxyBaseUrl(); + const response = await fetch(`${baseUrl}/public/litellm_blog_posts`); + if (!response.ok) { + throw new Error(`Failed to fetch blog posts: ${response.statusText}`); + } + return response.json(); +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr + "T00:00:00"); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export const BlogDropdown: React.FC = () => { + const disableShowBlog = useDisableShowBlog(); + + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["blogPosts"], + queryFn: fetchBlogPosts, + staleTime: 60 * 60 * 1000, // 1 hour — matches server-side TTL + }); + + if (disableShowBlog) { + return null; + } + + const dropdownContent = () => { + if (isError) { + return ( +
+ + Failed to load blog posts + + +
+ ); + } + + if (!data || data.posts.length === 0) { + return ( +
+ No posts available +
+ ); + } + + return ( + + ); + }; + + return ( + + + + ); +}; + +export default BlogDropdown; diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/__tests__/BlogDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/__tests__/BlogDropdown.test.tsx new file mode 100644 index 000000000000..f5a79648c772 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/__tests__/BlogDropdown.test.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { BlogDropdown } from "../BlogDropdown"; +import { useDisableShowBlog } from "@/app/(dashboard)/hooks/useDisableShowBlog"; + +// Mock hooks +vi.mock("@/app/(dashboard)/hooks/useDisableShowBlog", () => ({ + useDisableShowBlog: vi.fn(() => false), +})); + +vi.mock("@/components/networking", () => ({ + getProxyBaseUrl: () => "http://localhost:4000", +})); + +const SAMPLE_POSTS = { + posts: [ + { + title: "Test Post 1", + description: "First test post description.", + date: "2026-02-01", + url: "https://www.litellm.ai/blog/test-1", + }, + { + title: "Test Post 2", + description: "Second test post description.", + date: "2026-01-15", + url: "https://www.litellm.ai/blog/test-2", + }, + ], +}; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe("BlogDropdown", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders the Blog button", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_POSTS, + }); + + render(, { wrapper: createWrapper() }); + expect(screen.getByText("Blog")).toBeInTheDocument(); + }); + + it("shows posts on success", async () => { + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => SAMPLE_POSTS, + }); + + render(, { wrapper: createWrapper() }); + + // Open the dropdown + fireEvent.click(screen.getByText("Blog")); + + await waitFor(() => { + expect(screen.getByText("Test Post 1")).toBeInTheDocument(); + expect(screen.getByText("Test Post 2")).toBeInTheDocument(); + }); + }); + + it("shows error message and Retry button on fetch failure", async () => { + global.fetch = vi.fn().mockRejectedValueOnce(new Error("Network error")); + + render(, { wrapper: createWrapper() }); + fireEvent.click(screen.getByText("Blog")); + + await waitFor(() => { + expect(screen.getByText(/Failed to load blog posts/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + }); + + it("calls refetch when Retry is clicked", async () => { + global.fetch = vi + .fn() + .mockRejectedValueOnce(new Error("Network error")) + .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_POSTS }); + + render(, { wrapper: createWrapper() }); + fireEvent.click(screen.getByText("Blog")); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + + await waitFor(() => { + expect(screen.getByText("Test Post 1")).toBeInTheDocument(); + }); + }); + + it("returns null when useDisableShowBlog is true", () => { + vi.mocked(useDisableShowBlog).mockReturnValue(true); + + const { container } = render(, { wrapper: createWrapper() }); + expect(container.firstChild).toBeNull(); + }); +}); From 1c0cfd7e3b72b8a1cf82d7dadef244b762adc3d5 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 17:11:53 -0800 Subject: [PATCH 11/18] fix: enforce 5-post limit in BlogDropdown and add cap test --- .../Navbar/BlogDropdown/BlogDropdown.tsx | 2 +- .../__tests__/BlogDropdown.test.tsx | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx index 6f8b29f8805d..022ebb1e68c9 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -77,7 +77,7 @@ export const BlogDropdown: React.FC = () => { return (
- {data.posts.map((post, index) => ( + {data.posts.slice(0, 5).map((post, index) => ( { }); }); + it("shows at most 5 posts", async () => { + const manyPosts = Array.from({ length: 8 }, (_, i) => ({ + title: `Post ${i + 1}`, + description: `Description ${i + 1}`, + date: "2026-02-01", + url: `https://www.litellm.ai/blog/post-${i + 1}`, + })); + + global.fetch = vi.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ posts: manyPosts }), + }); + + render(, { wrapper: createWrapper() }); + fireEvent.click(screen.getByText("Blog")); + + await waitFor(() => { + expect(screen.getByText("Post 1")).toBeInTheDocument(); + expect(screen.getByText("Post 5")).toBeInTheDocument(); + expect(screen.queryByText("Post 6")).not.toBeInTheDocument(); + }); + }); + it("shows error message and Retry button on fetch failure", async () => { global.fetch = vi.fn().mockRejectedValueOnce(new Error("Network error")); From 929d592ef48e09240c200cecac8307f782b9f94a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 17:15:14 -0800 Subject: [PATCH 12/18] fix: add retry, stable post key, enabled guard in BlogDropdown Co-Authored-By: Claude Sonnet 4.6 --- .../BlogDropdown/{__tests__ => }/BlogDropdown.test.tsx | 10 +++++++--- .../components/Navbar/BlogDropdown/BlogDropdown.tsx | 7 +++++-- 2 files changed, 12 insertions(+), 5 deletions(-) rename ui/litellm-dashboard/src/components/Navbar/BlogDropdown/{__tests__ => }/BlogDropdown.test.tsx (92%) diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/__tests__/BlogDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx similarity index 92% rename from ui/litellm-dashboard/src/components/Navbar/BlogDropdown/__tests__/BlogDropdown.test.tsx rename to ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx index 6f448e5b60b9..fa1baa311d46 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/__tests__/BlogDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, it, expect, vi, beforeEach } from "vitest"; -import { BlogDropdown } from "../BlogDropdown"; +import { BlogDropdown } from "./BlogDropdown"; import { useDisableShowBlog } from "@/app/(dashboard)/hooks/useDisableShowBlog"; // Mock hooks @@ -33,7 +33,7 @@ const SAMPLE_POSTS = { function createWrapper() { const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, + defaultOptions: { queries: { retry: false, retryDelay: 0 } }, }); return ({ children }: { children: React.ReactNode }) => ( {children} @@ -96,7 +96,10 @@ describe("BlogDropdown", () => { }); it("shows error message and Retry button on fetch failure", async () => { - global.fetch = vi.fn().mockRejectedValueOnce(new Error("Network error")); + global.fetch = vi + .fn() + .mockRejectedValueOnce(new Error("Network error")) + .mockRejectedValueOnce(new Error("Network error")); render(, { wrapper: createWrapper() }); fireEvent.click(screen.getByText("Blog")); @@ -111,6 +114,7 @@ describe("BlogDropdown", () => { global.fetch = vi .fn() .mockRejectedValueOnce(new Error("Network error")) + .mockRejectedValueOnce(new Error("Network error")) .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_POSTS }); render(, { wrapper: createWrapper() }); diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx index 022ebb1e68c9..20cfe99ae44f 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -47,6 +47,9 @@ export const BlogDropdown: React.FC = () => { queryKey: ["blogPosts"], queryFn: fetchBlogPosts, staleTime: 60 * 60 * 1000, // 1 hour — matches server-side TTL + retry: 1, + retryDelay: 0, + enabled: !disableShowBlog, }); if (disableShowBlog) { @@ -77,9 +80,9 @@ export const BlogDropdown: React.FC = () => { return (
- {data.posts.slice(0, 5).map((post, index) => ( + {data.posts.slice(0, 5).map((post) => ( Date: Sat, 21 Feb 2026 17:16:16 -0800 Subject: [PATCH 13/18] feat: add BlogDropdown to navbar after Docs link --- ui/litellm-dashboard/src/components/navbar.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 2ffa0632f277..fe6fbb4c3a74 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -12,6 +12,7 @@ import { import { Switch, Tag } from "antd"; import Link from "next/link"; import React, { useEffect, useState } from "react"; +import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown"; import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons"; import UserDropdown from "./Navbar/UserDropdown/UserDropdown"; @@ -146,6 +147,7 @@ const Navbar: React.FC = ({ > Docs + {!isPublicPage && ( From 1ecfbad46e8d0814a1aecf78c538a4d05b99697a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 14:45:05 -0800 Subject: [PATCH 14/18] adjust blog posts to fetch from github first --- litellm/__init__.py | 4 ++++ litellm/blog_posts_backup.json | 16 ---------------- litellm/litellm_core_utils/get_blog_posts.py | 13 ++++--------- .../proxy/public_endpoints/public_endpoints.py | 3 ++- .../ui_crud_endpoints/proxy_setting_endpoints.py | 6 ------ .../test_proxy_setting_endpoints.py | 13 ------------- tests/test_litellm/test_get_blog_posts.py | 14 +++++++------- ui/litellm-dashboard/package.json | 1 + 8 files changed, 18 insertions(+), 52 deletions(-) delete mode 100644 litellm/blog_posts_backup.json delete mode 100644 tests/proxy_unit_tests/test_proxy_setting_endpoints.py diff --git a/litellm/__init__.py b/litellm/__init__.py index 97f36a9b00f7..1a7acd82c5d9 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -339,6 +339,10 @@ "LITELLM_MODEL_COST_MAP_URL", "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json", ) +blog_posts_url: str = os.getenv( + "LITELLM_BLOG_POSTS_URL", + "https://raw.githubusercontent.com/BerriAI/litellm/main/blog_posts.json", +) anthropic_beta_headers_url: str = os.getenv( "LITELLM_ANTHROPIC_BETA_HEADERS_URL", "https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/anthropic_beta_headers_config.json", diff --git a/litellm/blog_posts_backup.json b/litellm/blog_posts_backup.json deleted file mode 100644 index 8e29e3c4e196..000000000000 --- a/litellm/blog_posts_backup.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "posts": [ - { - "title": "LiteLLM: Unified Interface for 100+ LLMs", - "description": "Learn how LiteLLM provides a single interface to call any LLM with OpenAI-compatible syntax.", - "date": "2026-02-01", - "url": "https://www.litellm.ai/blog/litellm" - }, - { - "title": "Using the LiteLLM Proxy for Load Balancing", - "description": "Set up the LiteLLM proxy server to load balance across multiple LLM providers and deployments.", - "date": "2026-01-15", - "url": "https://www.litellm.ai/blog/proxy-load-balancing" - } - ] -} diff --git a/litellm/litellm_core_utils/get_blog_posts.py b/litellm/litellm_core_utils/get_blog_posts.py index 137e32b0c9e6..35d39ac62bee 100644 --- a/litellm/litellm_core_utils/get_blog_posts.py +++ b/litellm/litellm_core_utils/get_blog_posts.py @@ -2,7 +2,7 @@ Pulls the latest LiteLLM blog posts from GitHub. Falls back to the bundled local backup on any failure. -GitHub JSON can be overridden via LITELLM_BLOG_POSTS_URL env var. +GitHub JSON URL is configured via litellm.blog_posts_url (or LITELLM_BLOG_POSTS_URL env var). Disable remote fetching entirely: export LITELLM_LOCAL_BLOG_POSTS=True @@ -19,11 +19,6 @@ from litellm import verbose_logger -BLOG_POSTS_GITHUB_URL: str = os.getenv( - "LITELLM_BLOG_POSTS_URL", - "https://raw.githubusercontent.com/BerriAI/litellm/main/blog_posts.json", -) - BLOG_POSTS_TTL_SECONDS: int = 3600 # 1 hour @@ -57,7 +52,7 @@ def load_local_blog_posts() -> List[Dict[str, str]]: """Load the bundled local backup blog posts.""" content = json.loads( files("litellm") - .joinpath("blog_posts_backup.json") + .joinpath("blog_posts.json") .read_text(encoding="utf-8") ) return content.get("posts", []) @@ -93,7 +88,7 @@ def validate_blog_posts(data: Any) -> bool: return True @classmethod - def get_blog_posts(cls, url: str = BLOG_POSTS_GITHUB_URL) -> List[Dict[str, str]]: + def get_blog_posts(cls, url: str) -> List[Dict[str, str]]: """ Return the blog posts list. @@ -129,6 +124,6 @@ def get_blog_posts(cls, url: str = BLOG_POSTS_GITHUB_URL) -> List[Dict[str, str] return cls._cached_posts -def get_blog_posts(url: str = BLOG_POSTS_GITHUB_URL) -> List[Dict[str, str]]: +def get_blog_posts(url: str) -> List[Dict[str, str]]: """Public entry point — returns the blog posts list.""" return GetBlogPosts.get_blog_posts(url=url) diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index b2fb3dd0f8a4..29c9cb571ca3 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -2,6 +2,7 @@ import os from typing import List +import litellm from fastapi import APIRouter, Depends, HTTPException from litellm._logging import verbose_logger @@ -213,7 +214,7 @@ async def get_litellm_blog_posts(): Falls back to the bundled local backup on any failure. """ try: - posts_data = get_blog_posts() + posts_data = get_blog_posts(url=litellm.blog_posts_url) except Exception as e: verbose_logger.warning( "LiteLLM: get_litellm_blog_posts endpoint fallback triggered: %s", str(e) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index f6795f12bbc5..b465d13bc701 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -93,11 +93,6 @@ class UISettings(BaseModel): description="If enabled, forwards client headers (e.g. Authorization) to the LLM API. Required for Claude Code with Max subscription.", ) - disable_show_blog: bool = Field( - default=False, - description="If true, hides the Blog dropdown from the UI navbar.", - ) - class UISettingsResponse(SettingsResponse): """Response model for UI settings""" @@ -112,7 +107,6 @@ class UISettingsResponse(SettingsResponse): "enabled_ui_pages_internal_users", "require_auth_for_public_ai_hub", "forward_client_headers_to_llm_api", - "disable_show_blog", } diff --git a/tests/proxy_unit_tests/test_proxy_setting_endpoints.py b/tests/proxy_unit_tests/test_proxy_setting_endpoints.py deleted file mode 100644 index 1bc509196898..000000000000 --- a/tests/proxy_unit_tests/test_proxy_setting_endpoints.py +++ /dev/null @@ -1,13 +0,0 @@ -def test_ui_settings_has_disable_show_blog_field(): - """UISettings model must include disable_show_blog.""" - from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import UISettings - - settings = UISettings() - assert hasattr(settings, "disable_show_blog") - assert settings.disable_show_blog is False # default - - -def test_allowed_ui_settings_fields_contains_disable_show_blog(): - from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ALLOWED_UI_SETTINGS_FIELDS - - assert "disable_show_blog" in ALLOWED_UI_SETTINGS_FIELDS diff --git a/tests/test_litellm/test_get_blog_posts.py b/tests/test_litellm/test_get_blog_posts.py index 3f36a5b5fa54..a17d78e0bb68 100644 --- a/tests/test_litellm/test_get_blog_posts.py +++ b/tests/test_litellm/test_get_blog_posts.py @@ -5,8 +5,8 @@ import pytest +import litellm from litellm.litellm_core_utils.get_blog_posts import ( - BLOG_POSTS_GITHUB_URL, BlogPost, BlogPostsResponse, GetBlogPosts, @@ -68,7 +68,7 @@ def test_get_blog_posts_success(): mock_response.raise_for_status = MagicMock() with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response): - posts = get_blog_posts() + posts = get_blog_posts(url=litellm.blog_posts_url) assert len(posts) == 1 assert posts[0]["title"] == "Test Post" @@ -80,7 +80,7 @@ def test_get_blog_posts_network_error_falls_back_to_local(): "litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=Exception("Network error"), ): - posts = get_blog_posts() + posts = get_blog_posts(url=litellm.blog_posts_url) assert isinstance(posts, list) assert len(posts) > 0 @@ -93,7 +93,7 @@ def test_get_blog_posts_invalid_json_falls_back_to_local(): mock_response.raise_for_status = MagicMock() with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response): - posts = get_blog_posts() + posts = get_blog_posts(url=litellm.blog_posts_url) assert isinstance(posts, list) assert len(posts) > 0 @@ -115,7 +115,7 @@ def mock_get(*args, **kwargs): return m with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", side_effect=mock_get): - posts = get_blog_posts() + posts = get_blog_posts(url=litellm.blog_posts_url) assert call_count == 0 # cache hit, no fetch assert len(posts) == 1 @@ -133,7 +133,7 @@ def test_get_blog_posts_ttl_expired_refetches(): with patch( "litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response ) as mock_get: - posts = get_blog_posts() + posts = get_blog_posts(url=litellm.blog_posts_url) mock_get.assert_called_once() assert len(posts) == 1 @@ -142,7 +142,7 @@ def test_get_blog_posts_ttl_expired_refetches(): def test_get_blog_posts_local_env_var_skips_remote(monkeypatch): monkeypatch.setenv("LITELLM_LOCAL_BLOG_POSTS", "true") with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get") as mock_get: - posts = get_blog_posts() + posts = get_blog_posts(url=litellm.blog_posts_url) mock_get.assert_not_called() assert isinstance(posts, list) assert len(posts) > 0 diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 164368eb6bae..b05d707d5abe 100644 --- a/ui/litellm-dashboard/package.json +++ b/ui/litellm-dashboard/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "dev:webpack": "next dev --webpack", "build": "next build", "start": "next start", "lint": "next lint", From 94425dff1ee1501dd49df04045df254367b42107 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 15:01:49 -0800 Subject: [PATCH 15/18] fixing path --- litellm/litellm_core_utils/get_blog_posts.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/get_blog_posts.py b/litellm/litellm_core_utils/get_blog_posts.py index 35d39ac62bee..4f054c78ffed 100644 --- a/litellm/litellm_core_utils/get_blog_posts.py +++ b/litellm/litellm_core_utils/get_blog_posts.py @@ -99,11 +99,9 @@ def get_blog_posts(cls, url: str) -> List[Dict[str, str]]: return cls.load_local_blog_posts() now = time.time() - if ( - cls._cached_posts is not None - and (now - cls._last_fetch_time) < BLOG_POSTS_TTL_SECONDS - ): - return cls._cached_posts + cached = cls._cached_posts + if cached is not None and (now - cls._last_fetch_time) < BLOG_POSTS_TTL_SECONDS: + return cached try: data = cls.fetch_remote_blog_posts(url) @@ -119,9 +117,10 @@ def get_blog_posts(cls, url: str) -> List[Dict[str, str]]: if not cls.validate_blog_posts(data): return cls.load_local_blog_posts() - cls._cached_posts = data["posts"] + posts = data["posts"] + cls._cached_posts = posts cls._last_fetch_time = now - return cls._cached_posts + return posts def get_blog_posts(url: str) -> List[Dict[str, str]]: From 54b7e1af993e88e2f3f2f7583e74ac58dd697094 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 15:12:05 -0800 Subject: [PATCH 16/18] adjust blog post path --- blog_posts.json | 16 ---------------- litellm/__init__.py | 2 +- litellm/blog_posts.json | 10 ++++++++++ 3 files changed, 11 insertions(+), 17 deletions(-) delete mode 100644 blog_posts.json create mode 100644 litellm/blog_posts.json diff --git a/blog_posts.json b/blog_posts.json deleted file mode 100644 index 8e29e3c4e196..000000000000 --- a/blog_posts.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "posts": [ - { - "title": "LiteLLM: Unified Interface for 100+ LLMs", - "description": "Learn how LiteLLM provides a single interface to call any LLM with OpenAI-compatible syntax.", - "date": "2026-02-01", - "url": "https://www.litellm.ai/blog/litellm" - }, - { - "title": "Using the LiteLLM Proxy for Load Balancing", - "description": "Set up the LiteLLM proxy server to load balance across multiple LLM providers and deployments.", - "date": "2026-01-15", - "url": "https://www.litellm.ai/blog/proxy-load-balancing" - } - ] -} diff --git a/litellm/__init__.py b/litellm/__init__.py index 60cc5514ec85..1e74b5692e4f 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -341,7 +341,7 @@ ) blog_posts_url: str = os.getenv( "LITELLM_BLOG_POSTS_URL", - "https://raw.githubusercontent.com/BerriAI/litellm/main/blog_posts.json", + "https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/blog_posts.json", ) anthropic_beta_headers_url: str = os.getenv( "LITELLM_ANTHROPIC_BETA_HEADERS_URL", diff --git a/litellm/blog_posts.json b/litellm/blog_posts.json new file mode 100644 index 000000000000..15340514bccb --- /dev/null +++ b/litellm/blog_posts.json @@ -0,0 +1,10 @@ +{ + "posts": [ + { + "title": "Incident Report: SERVER_ROOT_PATH regression broke UI routing", + "description": "How a single line removal caused UI 404s for all deployments using SERVER_ROOT_PATH, and the tests we added to prevent it from happening again.", + "date": "2026-02-21", + "url": "https://docs.litellm.ai/blog/server-root-path-incident" + } + ] +} From 09cc3b8bbc0bb8d41dcc12b5ff711cff139a592e Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 15:54:07 -0800 Subject: [PATCH 17/18] ui changes --- .../hooks/blogPosts/useBlogPosts.ts | 32 ++ .../(dashboard)/hooks/useDisableBlogPosts.ts | 33 ++ .../(dashboard)/hooks/useDisableShowBlog.ts | 6 - .../Navbar/BlogDropdown/BlogDropdown.test.tsx | 292 ++++++++++++------ .../Navbar/BlogDropdown/BlogDropdown.tsx | 176 ++++------- .../Navbar/UserDropdown/UserDropdown.tsx | 19 ++ .../src/components/navbar.tsx | 42 +-- 7 files changed, 347 insertions(+), 253 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts new file mode 100644 index 000000000000..81d55e87650c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts @@ -0,0 +1,32 @@ +import { getProxyBaseUrl } from "@/components/networking"; +import { useQuery } from "@tanstack/react-query"; + +export interface BlogPost { + title: string; + description: string; + date: string; + url: string; +} + +export interface BlogPostsResponse { + posts: BlogPost[]; +} + +async function fetchBlogPosts(): Promise { + const baseUrl = getProxyBaseUrl(); + const response = await fetch(`${baseUrl}/public/litellm_blog_posts`); + if (!response.ok) { + throw new Error(`Failed to fetch blog posts: ${response.statusText}`); + } + return response.json(); +} + +export const useBlogPosts = () => { + return useQuery({ + queryKey: ["blogPosts"], + queryFn: fetchBlogPosts, + staleTime: 60 * 60 * 1000, + retry: 1, + retryDelay: 0, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts new file mode 100644 index 000000000000..a7b37b78d42a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts @@ -0,0 +1,33 @@ +import { LOCAL_STORAGE_EVENT, getLocalStorageItem } from "@/utils/localStorageUtils"; +import { useSyncExternalStore } from "react"; + +function subscribe(callback: () => void) { + const onStorage = (e: StorageEvent) => { + if (e.key === "disableBlogPosts") { + callback(); + } + }; + + const onCustom = (e: Event) => { + const { key } = (e as CustomEvent).detail; + if (key === "disableBlogPosts") { + callback(); + } + }; + + window.addEventListener("storage", onStorage); + window.addEventListener(LOCAL_STORAGE_EVENT, onCustom); + + return () => { + window.removeEventListener("storage", onStorage); + window.removeEventListener(LOCAL_STORAGE_EVENT, onCustom); + }; +} + +function getSnapshot() { + return getLocalStorageItem("disableBlogPosts") === "true"; +} + +export function useDisableBlogPosts() { + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts deleted file mode 100644 index cbe1bdc12501..000000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowBlog.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useUISettings } from "./uiSettings/useUISettings"; - -export function useDisableShowBlog(): boolean { - const { data } = useUISettings(); - return (data?.values?.disable_show_blog as boolean) ?? false; -} diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx index fa1baa311d46..05cb4f62aba2 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx @@ -1,140 +1,230 @@ -import React from "react"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils"; import { BlogDropdown } from "./BlogDropdown"; -import { useDisableShowBlog } from "@/app/(dashboard)/hooks/useDisableShowBlog"; -// Mock hooks -vi.mock("@/app/(dashboard)/hooks/useDisableShowBlog", () => ({ - useDisableShowBlog: vi.fn(() => false), -})); +let mockDisableBlogPosts = false; +let mockRefetch = vi.fn(); +let mockUseBlogPostsResult: { + data: { posts: { title: string; date: string; description: string; url: string }[] } | null | undefined; + isLoading: boolean; + isError: boolean; + refetch: () => void; +} = { + data: undefined, + isLoading: false, + isError: false, + refetch: mockRefetch, +}; -vi.mock("@/components/networking", () => ({ - getProxyBaseUrl: () => "http://localhost:4000", +vi.mock("@/app/(dashboard)/hooks/useDisableBlogPosts", () => ({ + useDisableBlogPosts: () => mockDisableBlogPosts, })); -const SAMPLE_POSTS = { - posts: [ - { - title: "Test Post 1", - description: "First test post description.", - date: "2026-02-01", - url: "https://www.litellm.ai/blog/test-1", - }, - { - title: "Test Post 2", - description: "Second test post description.", - date: "2026-01-15", - url: "https://www.litellm.ai/blog/test-2", - }, - ], -}; +vi.mock("@/app/(dashboard)/hooks/blogPosts/useBlogPosts", () => ({ + useBlogPosts: () => mockUseBlogPostsResult, +})); -function createWrapper() { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, retryDelay: 0 } }, - }); - return ({ children }: { children: React.ReactNode }) => ( - {children} - ); +const MOCK_POSTS = [ + { title: "Post One", date: "2026-02-01", description: "Description one", url: "https://example.com/1" }, + { title: "Post Two", date: "2026-02-02", description: "Description two", url: "https://example.com/2" }, + { title: "Post Three", date: "2026-02-03", description: "Description three", url: "https://example.com/3" }, + { title: "Post Four", date: "2026-02-04", description: "Description four", url: "https://example.com/4" }, + { title: "Post Five", date: "2026-02-05", description: "Description five", url: "https://example.com/5" }, + { title: "Post Six", date: "2026-02-06", description: "Description six", url: "https://example.com/6" }, +]; + +async function openDropdown() { + const user = userEvent.setup(); + await user.hover(screen.getByRole("button", { name: /blog/i })); } describe("BlogDropdown", () => { beforeEach(() => { vi.clearAllMocks(); + mockDisableBlogPosts = false; + mockRefetch = vi.fn(); + mockUseBlogPostsResult = { + data: undefined, + isLoading: false, + isError: false, + refetch: mockRefetch, + }; }); - it("renders the Blog button", async () => { - global.fetch = vi.fn().mockResolvedValueOnce({ - ok: true, - json: async () => SAMPLE_POSTS, + describe("when blog posts are disabled", () => { + it("renders nothing", () => { + mockDisableBlogPosts = true; + const { container } = renderWithProviders(); + expect(container).toBeEmptyDOMElement(); }); - - render(, { wrapper: createWrapper() }); - expect(screen.getByText("Blog")).toBeInTheDocument(); }); - it("shows posts on success", async () => { - global.fetch = vi.fn().mockResolvedValueOnce({ - ok: true, - json: async () => SAMPLE_POSTS, + describe("when blog posts are enabled", () => { + it("renders the Blog trigger button", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument(); }); - render(, { wrapper: createWrapper() }); + describe("loading state", () => { + it("shows a loading spinner", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true }; + renderWithProviders(); - // Open the dropdown - fireEvent.click(screen.getByText("Blog")); + await openDropdown(); - await waitFor(() => { - expect(screen.getByText("Test Post 1")).toBeInTheDocument(); - expect(screen.getByText("Test Post 2")).toBeInTheDocument(); + await waitFor(() => { + expect(document.querySelector(".anticon-loading")).toBeInTheDocument(); + }); + }); }); - }); - it("shows at most 5 posts", async () => { - const manyPosts = Array.from({ length: 8 }, (_, i) => ({ - title: `Post ${i + 1}`, - description: `Description ${i + 1}`, - date: "2026-02-01", - url: `https://www.litellm.ai/blog/post-${i + 1}`, - })); - - global.fetch = vi.fn().mockResolvedValueOnce({ - ok: true, - json: async () => ({ posts: manyPosts }), - }); + describe("error state", () => { + beforeEach(() => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isError: true }; + }); - render(, { wrapper: createWrapper() }); - fireEvent.click(screen.getByText("Blog")); + it("shows an error message", async () => { + renderWithProviders(); - await waitFor(() => { - expect(screen.getByText("Post 1")).toBeInTheDocument(); - expect(screen.getByText("Post 5")).toBeInTheDocument(); - expect(screen.queryByText("Post 6")).not.toBeInTheDocument(); - }); - }); + await openDropdown(); - it("shows error message and Retry button on fetch failure", async () => { - global.fetch = vi - .fn() - .mockRejectedValueOnce(new Error("Network error")) - .mockRejectedValueOnce(new Error("Network error")); + await waitFor(() => { + expect(screen.getByText("Failed to load posts")).toBeInTheDocument(); + }); + }); - render(, { wrapper: createWrapper() }); - fireEvent.click(screen.getByText("Blog")); + it("shows a Retry button", async () => { + renderWithProviders(); - await waitFor(() => { - expect(screen.getByText(/Failed to load blog posts/i)).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + await openDropdown(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + }); + + it("calls refetch when Retry is clicked", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.hover(screen.getByRole("button", { name: /blog/i })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("button", { name: /retry/i })); + + expect(mockRefetch).toHaveBeenCalledTimes(1); + }); }); - }); - it("calls refetch when Retry is clicked", async () => { - global.fetch = vi - .fn() - .mockRejectedValueOnce(new Error("Network error")) - .mockRejectedValueOnce(new Error("Network error")) - .mockResolvedValueOnce({ ok: true, json: async () => SAMPLE_POSTS }); + describe("empty state", () => { + it("shows 'No posts available' when data is null", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: null }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("No posts available")).toBeInTheDocument(); + }); + }); - render(, { wrapper: createWrapper() }); - fireEvent.click(screen.getByText("Blog")); + it("shows 'No posts available' when posts array is empty", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: [] } }; + renderWithProviders(); - await waitFor(() => { - expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("No posts available")).toBeInTheDocument(); + }); + }); }); - fireEvent.click(screen.getByRole("button", { name: /retry/i })); + describe("with posts", () => { + beforeEach(() => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 3) } }; + }); + + it("renders post titles", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Post One")).toBeInTheDocument(); + expect(screen.getByText("Post Two")).toBeInTheDocument(); + expect(screen.getByText("Post Three")).toBeInTheDocument(); + }); + }); + + it("renders post descriptions", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Description one")).toBeInTheDocument(); + }); + }); + + it("renders post links with correct attributes", async () => { + renderWithProviders(); + + await openDropdown(); - await waitFor(() => { - expect(screen.getByText("Test Post 1")).toBeInTheDocument(); + await waitFor(() => { + const link = screen.getByRole("link", { name: /post one/i }); + expect(link).toHaveAttribute("href", "https://example.com/1"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); + + it("renders formatted post dates", async () => { + mockUseBlogPostsResult = { + ...mockUseBlogPostsResult, + data: { posts: [{ title: "Date Post", date: "2026-02-15", description: "Desc", url: "https://example.com" }] }, + }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Feb 15, 2026")).toBeInTheDocument(); + }); + }); + + it("renders the 'View all posts' link", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + const viewAllLink = screen.getByRole("link", { name: /view all posts/i }); + expect(viewAllLink).toHaveAttribute("href", "https://docs.litellm.ai/blog"); + expect(viewAllLink).toHaveAttribute("target", "_blank"); + expect(viewAllLink).toHaveAttribute("rel", "noopener noreferrer"); + }); + }); }); - }); - it("returns null when useDisableShowBlog is true", () => { - vi.mocked(useDisableShowBlog).mockReturnValue(true); + describe("post limit", () => { + it("renders at most 5 posts when more than 5 are provided", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS } }; + renderWithProviders(); - const { container } = render(, { wrapper: createWrapper() }); - expect(container.firstChild).toBeNull(); + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Post One")).toBeInTheDocument(); + expect(screen.getByText("Post Five")).toBeInTheDocument(); + expect(screen.queryByText("Post Six")).not.toBeInTheDocument(); + }); + }); + }); }); }); diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx index 20cfe99ae44f..ddb2a33cdaa2 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -1,35 +1,11 @@ -import { useDisableShowBlog } from "@/app/(dashboard)/hooks/useDisableShowBlog"; -import { getProxyBaseUrl } from "@/components/networking"; -import { - DownOutlined, - LoadingOutlined, - ReadOutlined, -} from "@ant-design/icons"; -import { useQuery } from "@tanstack/react-query"; +import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; +import { useBlogPosts, type BlogPost } from "@/app/(dashboard)/hooks/blogPosts/useBlogPosts"; +import { LoadingOutlined } from "@ant-design/icons"; import { Button, Dropdown, Space, Typography } from "antd"; +import type { MenuProps } from "antd"; import React from "react"; -const { Text } = Typography; - -interface BlogPost { - title: string; - description: string; - date: string; - url: string; -} - -interface BlogPostsResponse { - posts: BlogPost[]; -} - -async function fetchBlogPosts(): Promise { - const baseUrl = getProxyBaseUrl(); - const response = await fetch(`${baseUrl}/public/litellm_blog_posts`); - if (!response.ok) { - throw new Error(`Failed to fetch blog posts: ${response.statusText}`); - } - return response.json(); -} +const { Text, Title, Paragraph } = Typography; function formatDate(dateStr: string): string { const date = new Date(dateStr + "T00:00:00"); @@ -41,106 +17,66 @@ function formatDate(dateStr: string): string { } export const BlogDropdown: React.FC = () => { - const disableShowBlog = useDisableShowBlog(); + const disableBlogPosts = useDisableBlogPosts(); - const { data, isLoading, isError, refetch } = useQuery({ - queryKey: ["blogPosts"], - queryFn: fetchBlogPosts, - staleTime: 60 * 60 * 1000, // 1 hour — matches server-side TTL - retry: 1, - retryDelay: 0, - enabled: !disableShowBlog, - }); + const { data, isLoading, isError, refetch } = useBlogPosts(); - if (disableShowBlog) { + if (disableBlogPosts) { return null; } - const dropdownContent = () => { - if (isError) { - return ( -
- - Failed to load blog posts - - -
- ); - } - - if (!data || data.posts.length === 0) { - return ( -
- No posts available -
- ); - } + let items: MenuProps["items"]; - return ( - - ); - }; + ), + })), + { type: "divider" as const }, + { + key: "view-all", + label: ( + + View all posts + + ), + }, + ]; + } return ( - - + + ); }; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 90e02ae447b8..2bef9a80778f 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -1,4 +1,5 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useDisableBlogPosts } from "@/app/(dashboard)/hooks/useDisableBlogPosts"; import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts"; import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator"; import { @@ -29,6 +30,7 @@ const UserDropdown: React.FC = ({ onLogout }) => { const { userId, userEmail, userRole, premiumUser } = useAuthorized(); const disableShowPrompts = useDisableShowPrompts(); const disableUsageIndicator = useDisableUsageIndicator(); + const disableBlogPosts = useDisableBlogPosts(); const [disableShowNewBadge, setDisableShowNewBadge] = useState(false); useEffect(() => { @@ -148,6 +150,23 @@ const UserDropdown: React.FC = ({ onLogout }) => { aria-label="Toggle hide usage indicator" /> + + Hide Blog Posts + { + if (checked) { + setLocalStorageItem("disableBlogPosts", "true"); + emitLocalStorageChange("disableBlogPosts"); + } else { + removeLocalStorageItem("disableBlogPosts"); + emitLocalStorageChange("disableBlogPosts"); + } + }} + aria-label="Toggle hide blog posts" + /> + ); diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index fe6fbb4c3a74..861fe0546462 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -3,13 +3,8 @@ import { getProxyBaseUrl } from "@/components/networking"; import { useTheme } from "@/contexts/ThemeContext"; import { clearTokenCookies } from "@/utils/cookieUtils"; import { fetchProxySettings } from "@/utils/proxyUtils"; -import { - MenuFoldOutlined, - MenuUnfoldOutlined, - MoonOutlined, - SunOutlined, -} from "@ant-design/icons"; -import { Switch, Tag } from "antd"; +import { MenuFoldOutlined, MenuUnfoldOutlined, MoonOutlined, SunOutlined } from "@ant-design/icons"; +import { Button, Switch, Tag } from "antd"; import Link from "next/link"; import React, { useEffect, useState } from "react"; import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown"; @@ -43,7 +38,7 @@ const Navbar: React.FC = ({ sidebarCollapsed = false, onToggleSidebar, isDarkMode, - toggleDarkMode + toggleDarkMode, }) => { const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); @@ -111,7 +106,7 @@ const Navbar: React.FC = ({ style={{ animationDuration: "2s" }} title="Thanks for using LiteLLM!" > - ❄️ + 🌑 = ({ {/* Dark mode is currently a work in progress. To test, you can change 'false' to 'true' below. Do not set this to true by default until all components are confirmed to support dark mode styles. */} - {false && } - unCheckedChildren={} - />} - + {false && ( + } + unCheckedChildren={} + /> + )} + - {!isPublicPage && ( - - )} + {!isPublicPage && }
From 9f3fc492c22ddd5b17b3973e406bf4775d71ea20 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Mon, 23 Feb 2026 16:02:36 -0800 Subject: [PATCH 18/18] adding tests --- .../Navbar/BlogDropdown/BlogDropdown.test.tsx | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx index 05cb4f62aba2..4ca0aa2aaef8 100644 --- a/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx @@ -53,7 +53,7 @@ describe("BlogDropdown", () => { }); describe("when blog posts are disabled", () => { - it("renders nothing", () => { + it("should render nothing", () => { mockDisableBlogPosts = true; const { container } = renderWithProviders(); expect(container).toBeEmptyDOMElement(); @@ -61,13 +61,13 @@ describe("BlogDropdown", () => { }); describe("when blog posts are enabled", () => { - it("renders the Blog trigger button", () => { + it("should render the Blog trigger button", () => { renderWithProviders(); expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument(); }); describe("loading state", () => { - it("shows a loading spinner", async () => { + it("should show a loading spinner", async () => { mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true }; renderWithProviders(); @@ -84,7 +84,7 @@ describe("BlogDropdown", () => { mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isError: true }; }); - it("shows an error message", async () => { + it("should show an error message", async () => { renderWithProviders(); await openDropdown(); @@ -94,7 +94,7 @@ describe("BlogDropdown", () => { }); }); - it("shows a Retry button", async () => { + it("should show a Retry button", async () => { renderWithProviders(); await openDropdown(); @@ -104,7 +104,7 @@ describe("BlogDropdown", () => { }); }); - it("calls refetch when Retry is clicked", async () => { + it("should call refetch when Retry is clicked", async () => { const user = userEvent.setup(); renderWithProviders(); @@ -121,7 +121,7 @@ describe("BlogDropdown", () => { }); describe("empty state", () => { - it("shows 'No posts available' when data is null", async () => { + it("should show 'No posts available' when data is null", async () => { mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: null }; renderWithProviders(); @@ -132,7 +132,7 @@ describe("BlogDropdown", () => { }); }); - it("shows 'No posts available' when posts array is empty", async () => { + it("should show 'No posts available' when posts array is empty", async () => { mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: [] } }; renderWithProviders(); @@ -149,7 +149,7 @@ describe("BlogDropdown", () => { mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 3) } }; }); - it("renders post titles", async () => { + it("should render post titles", async () => { renderWithProviders(); await openDropdown(); @@ -161,7 +161,7 @@ describe("BlogDropdown", () => { }); }); - it("renders post descriptions", async () => { + it("should render post descriptions", async () => { renderWithProviders(); await openDropdown(); @@ -171,7 +171,7 @@ describe("BlogDropdown", () => { }); }); - it("renders post links with correct attributes", async () => { + it("should render post links with correct attributes", async () => { renderWithProviders(); await openDropdown(); @@ -184,7 +184,7 @@ describe("BlogDropdown", () => { }); }); - it("renders formatted post dates", async () => { + it("should render formatted post dates", async () => { mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: [{ title: "Date Post", date: "2026-02-15", description: "Desc", url: "https://example.com" }] }, @@ -198,7 +198,7 @@ describe("BlogDropdown", () => { }); }); - it("renders the 'View all posts' link", async () => { + it("should render the 'View all posts' link", async () => { renderWithProviders(); await openDropdown(); @@ -213,7 +213,7 @@ describe("BlogDropdown", () => { }); describe("post limit", () => { - it("renders at most 5 posts when more than 5 are provided", async () => { + it("should render at most 5 posts when more than 5 are provided", async () => { mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS } }; renderWithProviders();