[Feature] UI - Blog Dropdown in Navbar#21859
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile SummaryAdds a Blog dropdown to the proxy UI navbar, backed by a new public endpoint
Confidence Score: 3/5
|
| 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
Last reviewed commit: 9f3fc49
| 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(); | ||
| }); |
There was a problem hiding this comment.
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)
| _cached_posts: Optional[List[Dict[str, str]]] = None | ||
| _last_fetch_time: float = 0.0 |
There was a problem hiding this comment.
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()| BLOG_POSTS_GITHUB_URL: str = os.getenv( | ||
| "LITELLM_BLOG_POSTS_URL", | ||
| "https://raw.githubusercontent.com/BerriAI/litellm/main/blog_posts.json", | ||
| ) |
There was a problem hiding this comment.
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)|
@greptile |
| 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) |
There was a problem hiding this comment.
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_poststo usehttpx.AsyncClientand makingget_blog_postsan async function.
| const disableBlogPosts = useDisableBlogPosts(); | ||
|
|
||
| const { data, isLoading, isError, refetch } = useBlogPosts(); |
There was a problem hiding this comment.
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:
| 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(); |
There was a problem hiding this comment.
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:
| 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!
[Feature] UI - Blog Dropdown in Navbar
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.jsonfile 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
GetBlogPostsutility (litellm/litellm_core_utils/get_blog_posts.py) with a 1-hour in-process TTL cache, GitHub fetch, and local fallback. New public endpointGET /public/litellm_blog_posts(no auth required) that returns up to 5 posts.BlogDropdowncomponent 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 intonavbar.tsxafter the existing Docs/Slack/GitHub community links.Testing
GetBlogPosts: TTL cache behavior, network fallback, local env var bypass (tests/test_litellm/test_get_blog_posts.py)/public/litellm_blog_posts: response shape, 5-post cap, fallback on upstream failure (tests/proxy_unit_tests/test_blog_posts_endpoint.py)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