diff --git a/litellm/__init__.py b/litellm/__init__.py index e2357f4a93..1e74b5692e 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -339,6 +339,10 @@ model_cost_map_url: str = os.getenv( "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/litellm/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.json b/litellm/blog_posts.json new file mode 100644 index 0000000000..15340514bc --- /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" + } + ] +} 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 0000000000..4f054c78ff --- /dev/null +++ b/litellm/litellm_core_utils/get_blog_posts.py @@ -0,0 +1,128 @@ +""" +Pulls the latest LiteLLM blog posts from GitHub. + +Falls back to the bundled local backup on any failure. +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 +""" + +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_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.json") + .read_text(encoding="utf-8") + ) + return content.get("posts", []) + + @staticmethod + def fetch_remote_blog_posts(url: str, timeout: int = 5) -> dict: + """ + 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) -> 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() + 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) + 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() + + posts = data["posts"] + cls._cached_posts = posts + cls._last_fetch_time = now + return posts + + +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 6d60a218fd..29c9cb571c 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -2,8 +2,16 @@ import json import os from typing import List +import litellm from fastapi import APIRouter, Depends, HTTPException +from litellm._logging import verbose_logger +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 +201,30 @@ 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(url=litellm.blog_posts_url) + except Exception as e: + verbose_logger.warning( + "LiteLLM: get_litellm_blog_posts endpoint fallback triggered: %s", str(e) + ) + posts_data = GetBlogPosts.load_local_blog_posts() + + posts = [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 0000000000..0f93f6f80c --- /dev/null +++ b/tests/proxy_unit_tests/test_blog_posts_endpoint.py @@ -0,0 +1,80 @@ +"""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() + assert len(response.json()["posts"]) > 0 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 0000000000..a17d78e0bb --- /dev/null +++ b/tests/test_litellm/test_get_blog_posts.py @@ -0,0 +1,165 @@ +"""Tests for GetBlogPosts utility class.""" +import json +import time +from unittest.mock import MagicMock, patch + +import pytest + +import litellm +from litellm.litellm_core_utils.get_blog_posts import ( + 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", + } + ] +} + + +@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) + 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(): + """Fetches from remote on first call.""" + 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(url=litellm.blog_posts_url) + + assert len(posts) == 1 + assert posts[0]["title"] == "Test Post" + + +def test_get_blog_posts_network_error_falls_back_to_local(): + """Falls back to local backup on network error.""" + with patch( + "litellm.litellm_core_utils.get_blog_posts.httpx.get", + side_effect=Exception("Network error"), + ): + posts = get_blog_posts(url=litellm.blog_posts_url) + + assert isinstance(posts, list) + assert len(posts) > 0 + + +def test_get_blog_posts_invalid_json_falls_back_to_local(): + """Falls back when remote returns non-dict.""" + 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(url=litellm.blog_posts_url) + + assert isinstance(posts, list) + assert len(posts) > 0 + + +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 + + 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(url=litellm.blog_posts_url) + + assert call_count == 0 # cache hit, no fetch + assert len(posts) == 1 + + +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 + + 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(url=litellm.blog_posts_url) + + mock_get.assert_called_once() + 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(url=litellm.blog_posts_url) + mock_get.assert_not_called() + assert isinstance(posts, list) + assert len(posts) > 0 + + +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 diff --git a/ui/litellm-dashboard/package.json b/ui/litellm-dashboard/package.json index 164368eb6b..b05d707d5a 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", 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 0000000000..81d55e8765 --- /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 0000000000..a7b37b78d4 --- /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/components/Navbar/BlogDropdown/BlogDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx new file mode 100644 index 0000000000..4ca0aa2aae --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx @@ -0,0 +1,230 @@ +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"; + +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("@/app/(dashboard)/hooks/useDisableBlogPosts", () => ({ + useDisableBlogPosts: () => mockDisableBlogPosts, +})); + +vi.mock("@/app/(dashboard)/hooks/blogPosts/useBlogPosts", () => ({ + useBlogPosts: () => mockUseBlogPostsResult, +})); + +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, + }; + }); + + describe("when blog posts are disabled", () => { + it("should render nothing", () => { + mockDisableBlogPosts = true; + const { container } = renderWithProviders(); + expect(container).toBeEmptyDOMElement(); + }); + }); + + describe("when blog posts are enabled", () => { + it("should render the Blog trigger button", () => { + renderWithProviders(); + expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument(); + }); + + describe("loading state", () => { + it("should show a loading spinner", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(document.querySelector(".anticon-loading")).toBeInTheDocument(); + }); + }); + }); + + describe("error state", () => { + beforeEach(() => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isError: true }; + }); + + it("should show an error message", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Failed to load posts")).toBeInTheDocument(); + }); + }); + + it("should show a Retry button", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + }); + + it("should call 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); + }); + }); + + describe("empty state", () => { + it("should show 'No posts available' when data is null", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: null }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("No posts available")).toBeInTheDocument(); + }); + }); + + it("should show 'No posts available' when posts array is empty", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: [] } }; + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("No posts available")).toBeInTheDocument(); + }); + }); + }); + + describe("with posts", () => { + beforeEach(() => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS.slice(0, 3) } }; + }); + + it("should render 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("should render post descriptions", async () => { + renderWithProviders(); + + await openDropdown(); + + await waitFor(() => { + expect(screen.getByText("Description one")).toBeInTheDocument(); + }); + }); + + it("should render post links with correct attributes", async () => { + renderWithProviders(); + + await openDropdown(); + + 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("should render 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("should render 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"); + }); + }); + }); + + describe("post limit", () => { + it("should render at most 5 posts when more than 5 are provided", async () => { + mockUseBlogPostsResult = { ...mockUseBlogPostsResult, data: { posts: MOCK_POSTS } }; + renderWithProviders(); + + 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 new file mode 100644 index 0000000000..ddb2a33cda --- /dev/null +++ b/ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx @@ -0,0 +1,84 @@ +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, Title, Paragraph } = Typography; + +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 disableBlogPosts = useDisableBlogPosts(); + + const { data, isLoading, isError, refetch } = useBlogPosts(); + + if (disableBlogPosts) { + return null; + } + + let items: MenuProps["items"]; + + if (isLoading) { + items = [{ key: "loading", label: , disabled: true }]; + } else if (isError) { + items = [ + { + key: "error", + label: ( + + Failed to load posts + + + ), + disabled: true, + }, + ]; + } else if (!data || data.posts.length === 0) { + items = [{ key: "empty", label: No posts available, disabled: true }]; + } else { + items = [ + ...data.posts.slice(0, 5).map((post: BlogPost) => ({ + key: post.url, + label: ( + + + {post.title} + + + {formatDate(post.date)} + + {post.description} + + ), + })), + { type: "divider" as const }, + { + key: "view-all", + label: ( + + View all posts + + ), + }, + ]; + } + + return ( + + + + ); +}; + +export default BlogDropdown; diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index 90e02ae447..2bef9a8077 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 2ffa0632f2..861fe05464 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -3,15 +3,11 @@ 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"; import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons"; import UserDropdown from "./Navbar/UserDropdown/UserDropdown"; @@ -42,7 +38,7 @@ const Navbar: React.FC = ({ sidebarCollapsed = false, onToggleSidebar, isDarkMode, - toggleDarkMode + toggleDarkMode, }) => { const baseUrl = getProxyBaseUrl(); const [logoutUrl, setLogoutUrl] = useState(""); @@ -110,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={} - />} - - Docs - - - {!isPublicPage && ( - + {false && ( + } + unCheckedChildren={} + /> )} + + + + {!isPublicPage && }