Merge pull request #21859 from BerriAI/litellm_blog_dropdown

[Feature] UI - Blog Dropdown in Navbar
This commit is contained in:
yuneng-jiang
2026-02-23 16:22:22 -08:00
committed by GitHub
13 changed files with 837 additions and 27 deletions
+4
View File
@@ -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",
+10
View File
@@ -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"
}
]
}
@@ -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)
@@ -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"],
@@ -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
+165
View File
@@ -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
+1
View File
@@ -4,6 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev",
"dev:webpack": "next dev --webpack",
"build": "next build",
"start": "next start",
"lint": "next lint",
@@ -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<BlogPostsResponse> {
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<BlogPostsResponse>({
queryKey: ["blogPosts"],
queryFn: fetchBlogPosts,
staleTime: 60 * 60 * 1000,
retry: 1,
retryDelay: 0,
});
};
@@ -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);
}
@@ -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(<BlogDropdown />);
expect(container).toBeEmptyDOMElement();
});
});
describe("when blog posts are enabled", () => {
it("should render the Blog trigger button", () => {
renderWithProviders(<BlogDropdown />);
expect(screen.getByRole("button", { name: /blog/i })).toBeInTheDocument();
});
describe("loading state", () => {
it("should show a loading spinner", async () => {
mockUseBlogPostsResult = { ...mockUseBlogPostsResult, isLoading: true };
renderWithProviders(<BlogDropdown />);
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(<BlogDropdown />);
await openDropdown();
await waitFor(() => {
expect(screen.getByText("Failed to load posts")).toBeInTheDocument();
});
});
it("should show a Retry button", async () => {
renderWithProviders(<BlogDropdown />);
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(<BlogDropdown />);
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(<BlogDropdown />);
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(<BlogDropdown />);
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(<BlogDropdown />);
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(<BlogDropdown />);
await openDropdown();
await waitFor(() => {
expect(screen.getByText("Description one")).toBeInTheDocument();
});
});
it("should render post links with correct attributes", async () => {
renderWithProviders(<BlogDropdown />);
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(<BlogDropdown />);
await openDropdown();
await waitFor(() => {
expect(screen.getByText("Feb 15, 2026")).toBeInTheDocument();
});
});
it("should render the 'View all posts' link", async () => {
renderWithProviders(<BlogDropdown />);
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(<BlogDropdown />);
await openDropdown();
await waitFor(() => {
expect(screen.getByText("Post One")).toBeInTheDocument();
expect(screen.getByText("Post Five")).toBeInTheDocument();
expect(screen.queryByText("Post Six")).not.toBeInTheDocument();
});
});
});
});
});
@@ -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: <LoadingOutlined />, disabled: true }];
} else if (isError) {
items = [
{
key: "error",
label: (
<Space>
<Text type="danger">Failed to load posts</Text>
<Button size="small" onClick={() => refetch()}>
Retry
</Button>
</Space>
),
disabled: true,
},
];
} else if (!data || data.posts.length === 0) {
items = [{ key: "empty", label: <Text type="secondary">No posts available</Text>, disabled: true }];
} else {
items = [
...data.posts.slice(0, 5).map((post: BlogPost) => ({
key: post.url,
label: (
<a href={post.url} target="_blank" rel="noopener noreferrer" style={{ display: "block", width: 380 }}>
<Title level={5} style={{ marginBottom: 2 }}>
{post.title}
</Title>
<Text type="secondary" style={{ fontSize: 11 }}>
{formatDate(post.date)}
</Text>
<Paragraph ellipsis={{ rows: 2 }}>{post.description}</Paragraph>
</a>
),
})),
{ type: "divider" as const },
{
key: "view-all",
label: (
<a href="https://docs.litellm.ai/blog" target="_blank" rel="noopener noreferrer">
View all posts
</a>
),
},
];
}
return (
<Dropdown menu={{ items }} trigger={["hover"]} placement="bottomRight">
<Button type="text">Blog</Button>
</Dropdown>
);
};
export default BlogDropdown;
@@ -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<UserDropdownProps> = ({ 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<UserDropdownProps> = ({ onLogout }) => {
aria-label="Toggle hide usage indicator"
/>
</Space>
<Space style={{ width: "100%", justifyContent: "space-between" }}>
<Text type="secondary">Hide Blog Posts</Text>
<Switch
size="small"
checked={disableBlogPosts}
onChange={(checked) => {
if (checked) {
setLocalStorageItem("disableBlogPosts", "true");
emitLocalStorageChange("disableBlogPosts");
} else {
removeLocalStorageItem("disableBlogPosts");
emitLocalStorageChange("disableBlogPosts");
}
}}
aria-label="Toggle hide blog posts"
/>
</Space>
</Space>
);
+19 -27
View File
@@ -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<NavbarProps> = ({
sidebarCollapsed = false,
onToggleSidebar,
isDarkMode,
toggleDarkMode
toggleDarkMode,
}) => {
const baseUrl = getProxyBaseUrl();
const [logoutUrl, setLogoutUrl] = useState("");
@@ -110,7 +106,7 @@ const Navbar: React.FC<NavbarProps> = ({
style={{ animationDuration: "2s" }}
title="Thanks for using LiteLLM!"
>
🌑
</span>
<Tag className="relative text-xs font-medium cursor-pointer z-10">
<a
@@ -131,25 +127,21 @@ const Navbar: React.FC<NavbarProps> = ({
<CommunityEngagementButtons />
{/* 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 && <Switch
data-testid="dark-mode-toggle"
checked={isDarkMode}
onChange={toggleDarkMode}
checkedChildren={<MoonOutlined />}
unCheckedChildren={<SunOutlined />}
/>}
<a
href="https://docs.litellm.ai/docs/"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
Docs
</a>
{!isPublicPage && (
<UserDropdown onLogout={handleLogout} />
{false && (
<Switch
data-testid="dark-mode-toggle"
checked={isDarkMode}
onChange={toggleDarkMode}
checkedChildren={<MoonOutlined />}
unCheckedChildren={<SunOutlined />}
/>
)}
<Button type="text" href="https://docs.litellm.ai/docs/" target="_blank" rel="noopener noreferrer">
Docs
</Button>
<BlogDropdown />
{!isPublicPage && <UserDropdown onLogout={handleLogout} />}
</div>
</div>
</div>