From 021a9097c4f1f5ae22de4b40dd69d7cdf453678a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Feb 2026 16:51:55 -0800 Subject: [PATCH] feat: add GET /public/litellm_blog_posts endpoint Co-Authored-By: Claude Sonnet 4.6 --- .../public_endpoints/public_endpoints.py | 27 +++++++ .../test_blog_posts_endpoint.py | 79 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 tests/proxy_unit_tests/test_blog_posts_endpoint.py diff --git a/litellm/proxy/public_endpoints/public_endpoints.py b/litellm/proxy/public_endpoints/public_endpoints.py index 6d60a218fd1..f5bca531839 100644 --- a/litellm/proxy/public_endpoints/public_endpoints.py +++ b/litellm/proxy/public_endpoints/public_endpoints.py @@ -4,6 +4,12 @@ from typing import List from fastapi import APIRouter, Depends, HTTPException +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 +199,27 @@ 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() + except Exception: + 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 00000000000..1c5db4d2cfe --- /dev/null +++ b/tests/proxy_unit_tests/test_blog_posts_endpoint.py @@ -0,0 +1,79 @@ +"""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()