mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-11 02:22:48 +00:00
Merge pull request #21859 from BerriAI/litellm_blog_dropdown
[Feature] UI - Blog Dropdown in Navbar
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user