Skip to content

[Feature] UI - Blog Dropdown in Navbar#21859

Merged
yuneng-jiang merged 19 commits intomainfrom
litellm_blog_dropdown
Feb 24, 2026
Merged

[Feature] UI - Blog Dropdown in Navbar#21859
yuneng-jiang merged 19 commits intomainfrom
litellm_blog_dropdown

Conversation

@yuneng-jiang
Copy link
Collaborator

@yuneng-jiang yuneng-jiang commented Feb 22, 2026

Relevant issues

Summary

Adds a Blog dropdown to the top navbar in the LiteLLM proxy UI. The dropdown fetches the latest blog posts from the GitHub-hosted blog_posts.json file and falls back to a bundled local JSON copy if the network request fails. This follows the same pattern used by the model cost map.

Changes

  • Backend: New GetBlogPosts utility (litellm/litellm_core_utils/get_blog_posts.py) with a 1-hour in-process TTL cache, GitHub fetch, and local fallback. New public endpoint GET /public/litellm_blog_posts (no auth required) that returns up to 5 posts.
  • Frontend: New BlogDropdown component using react-query. Shows a loading indicator while fetching, an inline error message with a Retry button on failure, and up to 5 posts (title, date, short description) on success. The component is wired into navbar.tsx after the existing Docs/Slack/GitHub community links.

Testing

  • Unit tests for GetBlogPosts: TTL cache behavior, network fallback, local env var bypass (tests/test_litellm/test_get_blog_posts.py)
  • Endpoint tests for /public/litellm_blog_posts: response shape, 5-post cap, fallback on upstream failure (tests/proxy_unit_tests/test_blog_posts_endpoint.py)
  • Component tests for BlogDropdown: success render, error+retry, disabled state, 5-post cap (ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx)

Type

🆕 New Feature
✅ Test

Screenshots

image

yuneng-jiang and others added 13 commits February 21, 2026 17:34
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.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ache duplication

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Feb 22, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
litellm Ready Ready Preview, Comment Feb 24, 2026 0:04am

Request Review

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 22, 2026

Greptile Summary

Adds a Blog dropdown to the proxy UI navbar, backed by a new public endpoint GET /public/litellm_blog_posts that fetches posts from GitHub with a 1-hour TTL cache and falls back to a bundled local JSON file. Includes a user toggle to hide blog posts via localStorage, following the same patterns as existing "Hide Prompts" and "Hide Usage Indicator" features.

  • Backend: New GetBlogPosts utility in litellm/litellm_core_utils/get_blog_posts.py mirrors the GetModelCostMap pattern. New public endpoint returns up to 5 posts with Pydantic validation.
  • Frontend: New BlogDropdown component with loading/error/empty/success states, useBlogPosts react-query hook with 1-hour stale time, and useDisableBlogPosts localStorage-backed toggle.
  • Key concern: The async endpoint calls synchronous httpx.get(), which will block the asyncio event loop for up to 5 seconds on every cache miss (once per hour). This should be wrapped in asyncio.to_thread() or converted to an async HTTP call.
  • Tests: Good coverage with properly mocked network calls in both tests/proxy_unit_tests/ and tests/test_litellm/.

Confidence Score: 3/5

  • Functionally correct but the synchronous HTTP call inside an async endpoint will block the event loop, which could degrade performance for all concurrent requests during cache refreshes.
  • Score of 3 reflects one notable issue: the synchronous httpx.get() call inside the async endpoint handler blocks the event loop for up to 5 seconds on cache miss. The frontend and test code are well-structured, and the feature follows existing patterns. The blocking call is the main concern preventing a higher score.
  • litellm/proxy/public_endpoints/public_endpoints.py (sync HTTP in async handler) and litellm/litellm_core_utils/get_blog_posts.py (synchronous fetch utility called from async context)

Important Files Changed

Filename Overview
litellm/init.py Adds blog_posts_url module-level config variable following the existing model_cost_map_url pattern. Clean addition.
litellm/litellm_core_utils/get_blog_posts.py New utility class that fetches/caches blog posts. Uses synchronous httpx.get() which will block the event loop when called from async endpoint. TTL cache pattern is reasonable but env var and thread-safety issues were noted in prior review.
litellm/proxy/public_endpoints/public_endpoints.py New /public/litellm_blog_posts endpoint. The async handler calls synchronous httpx.get() which blocks the event loop for up to 5 seconds on cache miss.
ui/litellm-dashboard/src/app/(dashboard)/hooks/blogPosts/useBlogPosts.ts New react-query hook for fetching blog posts. Clean implementation. Could benefit from an enabled option to skip fetching when disabled.
ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx Comprehensive test suite for BlogDropdown. Uses document.querySelector on line 77 which violates AGENTS.md testing guidelines. Tests are otherwise well-structured.
ui/litellm-dashboard/src/components/Navbar/BlogDropdown/BlogDropdown.tsx New BlogDropdown component with loading/error/empty/success states. Well-structured. useBlogPosts fires even when disabled, causing unnecessary API calls.

Sequence Diagram

sequenceDiagram
    participant UI as BlogDropdown (UI)
    participant API as /public/litellm_blog_posts
    participant Cache as In-Process Cache
    participant GH as GitHub Raw
    participant Local as Local blog_posts.json

    UI->>API: GET /public/litellm_blog_posts
    API->>Cache: Check TTL (1 hour)
    alt Cache hit
        Cache-->>API: Cached posts
    else Cache miss
        API->>GH: httpx.get() (sync, 5s timeout)
        alt Success
            GH-->>API: JSON response
            API->>Cache: Store posts + timestamp
        else Failure
            API->>Local: Load bundled fallback
            Local-->>API: Local posts
        end
    end
    API-->>UI: BlogPostsResponse (max 5 posts)
    UI->>UI: Render dropdown with posts
Loading

Last reviewed commit: 9f3fc49

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

12 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +48 to +56
it("renders the Blog button", async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => SAMPLE_POSTS,
});

render(<BlogDropdown />, { wrapper: createWrapper() });
expect(screen.getByText("Blog")).toBeInTheDocument();
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test names must start with "should"

Per the project's AGENTS.md testing conventions, all it() test names should follow the pattern it("should ..."). The existing tests in the same Navbar/ directory (e.g., CommunityEngagementButtons.test.tsx, UserDropdown.test.tsx) all use this convention. These tests use descriptions like "renders the Blog button", "shows posts on success", etc. instead.

For example:

  • it("renders the Blog button", ...)it("should render the Blog button", ...)
  • it("shows posts on success", ...)it("should show posts on success", ...)
  • it("shows at most 5 posts", ...)it("should show at most 5 posts", ...)
  • it("shows error message and Retry button on fetch failure", ...)it("should show error message and Retry button on fetch failure", ...)
  • it("calls refetch when Retry is clicked", ...)it("should call refetch when Retry is clicked", ...)
  • it("returns null when useDisableShowBlog is true", ...)it("should return null when useDisableShowBlog is true", ...)

Context Used: Context from dashboard - AGENTS.md (source)

Comment on lines +52 to +53
_cached_posts: Optional[List[Dict[str, str]]] = None
_last_fetch_time: float = 0.0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Class-level mutable cache is not thread-safe

_cached_posts and _last_fetch_time are class-level mutable variables shared across all threads. In a multi-threaded FastAPI deployment (or when using asyncio.to_thread), concurrent requests could create a race condition in get_blog_posts() where one thread reads _cached_posts while another thread is updating it after the TTL check. While the impact is low for a blog post list (worst case: an extra remote fetch), consider protecting these with a threading.Lock for correctness, particularly since the GetModelCostMap class this is modeled after has the same issue.

import threading

class GetBlogPosts:
    _cached_posts: Optional[List[Dict[str, str]]] = None
    _last_fetch_time: float = 0.0
    _lock: threading.Lock = threading.Lock()

Comment on lines +22 to +25
BLOG_POSTS_GITHUB_URL: str = os.getenv(
"LITELLM_BLOG_POSTS_URL",
"https://raw.githubusercontent.com/BerriAI/litellm/main/blog_posts.json",
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Env var is resolved at import time, not at call time

BLOG_POSTS_GITHUB_URL is computed once at module import via os.getenv(). It then becomes the default parameter value for get_blog_posts() and get_blog_posts(). If a user sets/changes the LITELLM_BLOG_POSTS_URL environment variable after the module has been imported (e.g., through the proxy config), the change won't take effect until the process is restarted. Consider reading the env var inside get_blog_posts() instead:

BLOG_POSTS_GITHUB_URL_DEFAULT = "https://raw.githubusercontent.com/BerriAI/litellm/main/blog_posts.json"

# Then inside get_blog_posts():
url = os.getenv("LITELLM_BLOG_POSTS_URL", BLOG_POSTS_GITHUB_URL_DEFAULT)

@yuneng-jiang
Copy link
Collaborator Author

@greptile

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

13 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +209 to +225
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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synchronous HTTP call blocks the async event loop

get_litellm_blog_posts is an async endpoint, but get_blog_posts() internally calls httpx.get() — a synchronous, blocking HTTP call with a 5-second timeout. When the cache has expired (every hour), this will block the entire asyncio event loop for up to 5 seconds, stalling all other concurrent requests being served by that worker.

Unlike get_litellm_model_cost_map (which reads a pre-loaded in-memory dict), this endpoint triggers a live HTTP fetch on each cache miss.

Consider either:

  • Running the blocking call in a thread via asyncio.to_thread:
import asyncio
posts_data = await asyncio.to_thread(get_blog_posts, url=litellm.blog_posts_url)
  • Or switching GetBlogPosts.fetch_remote_blog_posts to use httpx.AsyncClient and making get_blog_posts an async function.

Comment on lines +20 to +22
const disableBlogPosts = useDisableBlogPosts();

const { data, isLoading, isError, refetch } = useBlogPosts();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useBlogPosts fires even when blog is hidden

useBlogPosts() is called unconditionally (which is correct for React's rules of hooks), but this means users who have disabled blog posts via "Hide Blog Posts" will still trigger an API call to /public/litellm_blog_posts on every page load.

Consider passing an enabled option to the query so it skips fetching when disabled:

Suggested change
const disableBlogPosts = useDisableBlogPosts();
const { data, isLoading, isError, refetch } = useBlogPosts();
const disableBlogPosts = useDisableBlogPosts();
const { data, isLoading, isError, refetch } = useBlogPosts(!disableBlogPosts);

Then in useBlogPosts.ts, accept the flag:

export const useBlogPosts = (enabled: boolean = true) => {
  return useQuery<BlogPostsResponse>({
    queryKey: ["blogPosts"],
    queryFn: fetchBlogPosts,
    staleTime: 60 * 60 * 1000,
    retry: 1,
    retryDelay: 0,
    enabled,
  });
};

await openDropdown();

await waitFor(() => {
expect(document.querySelector(".anticon-loading")).toBeInTheDocument();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer React Testing Library queries over querySelector

Per AGENTS.md: "Avoid using querySelector: Prefer React Testing Library queries over direct DOM manipulation." This line uses document.querySelector(".anticon-loading") instead of a React Testing Library query.

Consider using getByRole with a spinner role, or getByTestId if the loading icon doesn't expose a semantic role:

Suggested change
expect(document.querySelector(".anticon-loading")).toBeInTheDocument();
expect(screen.getByRole("img", { name: /loading/i })).toBeInTheDocument();

If antd's LoadingOutlined doesn't expose an accessible role, you could use screen.getByLabelText or add a data-testid to the loading indicator in the component.

Context Used: Context from dashboard - AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@yuneng-jiang yuneng-jiang merged commit a749598 into main Feb 24, 2026
83 of 93 checks passed
damhau pushed a commit to damhau/litellm that referenced this pull request Feb 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant