Skip to content

Commit ef9cc33

Browse files
authored
Merge pull request #23822 from BerriAI/litellm_ryan_march_16
Litellm ryan's daily branch march 16
2 parents a622a1f + 302292c commit ef9cc33

8 files changed

Lines changed: 344 additions & 67 deletions

File tree

litellm/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@
358358
)
359359
blog_posts_url: str = os.getenv(
360360
"LITELLM_BLOG_POSTS_URL",
361-
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/blog_posts.json",
361+
"https://docs.litellm.ai/blog/rss.xml",
362362
)
363363
anthropic_beta_headers_url: str = os.getenv(
364364
"LITELLM_ANTHROPIC_BETA_HEADERS_URL",

litellm/litellm_core_utils/get_blog_posts.py

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
"""
2-
Pulls the latest LiteLLM blog posts from GitHub.
2+
Pulls the latest LiteLLM blog posts from the docs RSS feed.
33
44
Falls back to the bundled local backup on any failure.
5-
GitHub JSON URL is configured via litellm.blog_posts_url (or LITELLM_BLOG_POSTS_URL env var).
5+
RSS URL is configured via litellm.blog_posts_url (or LITELLM_BLOG_POSTS_URL env var).
66
77
Disable remote fetching entirely:
88
export LITELLM_LOCAL_BLOG_POSTS=True
@@ -11,8 +11,10 @@
1111
import json
1212
import os
1313
import time
14+
import xml.etree.ElementTree as ET
15+
from email.utils import parsedate_to_datetime
1416
from importlib.resources import files
15-
from typing import Any, Dict, List, Optional
17+
from typing import Dict, List, Optional
1618

1719
import httpx
1820
from pydantic import BaseModel
@@ -37,9 +39,8 @@ class GetBlogPosts:
3739
"""
3840
Fetches, validates, and caches LiteLLM blog posts.
3941
40-
Mirrors the structure of GetModelCostMap:
41-
- Fetches from GitHub with a 5-second timeout
42-
- Validates the response has a non-empty ``posts`` list
42+
- Fetches RSS feed from docs site with a 5-second timeout
43+
- Parses the XML and extracts the latest blog post
4344
- Caches the result in-process for BLOG_POSTS_TTL_SECONDS (1 hour)
4445
- Falls back to the bundled local backup on any failure
4546
"""
@@ -56,30 +57,67 @@ def load_local_blog_posts() -> List[Dict[str, str]]:
5657
return content.get("posts", [])
5758

5859
@staticmethod
59-
def fetch_remote_blog_posts(url: str, timeout: int = 5) -> dict:
60+
def fetch_rss_feed(url: str, timeout: int = 5) -> str:
6061
"""
61-
Fetch blog posts JSON from a remote URL.
62+
Fetch RSS XML from a remote URL.
6263
63-
Returns the parsed response. Raises on network/parse errors.
64+
Returns the raw XML text. Raises on network errors.
6465
"""
6566
response = httpx.get(url, timeout=timeout)
6667
response.raise_for_status()
67-
return response.json()
68+
return response.text
6869

6970
@staticmethod
70-
def validate_blog_posts(data: Any) -> bool:
71-
"""Return True if data is a dict with a non-empty ``posts`` list."""
72-
if not isinstance(data, dict):
73-
verbose_logger.warning(
74-
"LiteLLM: Blog posts response is not a dict (type=%s). "
75-
"Falling back to local backup.",
76-
type(data).__name__,
71+
def parse_rss_to_posts(xml_text: str, max_posts: int = 1) -> List[Dict[str, str]]:
72+
"""
73+
Parse RSS XML and return a list of blog post dicts.
74+
75+
Extracts title, description, date (YYYY-MM-DD), and url from each <item>.
76+
"""
77+
root = ET.fromstring(xml_text)
78+
channel = root.find("channel")
79+
if channel is None:
80+
raise ValueError("RSS feed missing <channel> element")
81+
82+
posts: List[Dict[str, str]] = []
83+
for item in channel.findall("item"):
84+
if len(posts) >= max_posts:
85+
break
86+
87+
title_el = item.find("title")
88+
link_el = item.find("link")
89+
desc_el = item.find("description")
90+
pub_date_el = item.find("pubDate")
91+
92+
if title_el is None or link_el is None:
93+
continue
94+
95+
# Parse RFC 2822 date to YYYY-MM-DD
96+
date_str = ""
97+
if pub_date_el is not None and pub_date_el.text:
98+
try:
99+
dt = parsedate_to_datetime(pub_date_el.text)
100+
date_str = dt.strftime("%Y-%m-%d")
101+
except Exception:
102+
date_str = pub_date_el.text
103+
104+
posts.append(
105+
{
106+
"title": title_el.text or "",
107+
"description": desc_el.text or "" if desc_el is not None else "",
108+
"date": date_str,
109+
"url": link_el.text or "",
110+
}
77111
)
78-
return False
79-
posts = data.get("posts")
112+
113+
return posts
114+
115+
@staticmethod
116+
def validate_blog_posts(posts: List[Dict[str, str]]) -> bool:
117+
"""Return True if posts is a non-empty list."""
80118
if not isinstance(posts, list) or len(posts) == 0:
81119
verbose_logger.warning(
82-
"LiteLLM: Blog posts response has no valid 'posts' list. "
120+
"LiteLLM: Parsed RSS feed has no valid posts. "
83121
"Falling back to local backup.",
84122
)
85123
return False
@@ -102,7 +140,8 @@ def get_blog_posts(cls, url: str) -> List[Dict[str, str]]:
102140
return cached
103141

104142
try:
105-
data = cls.fetch_remote_blog_posts(url)
143+
xml_text = cls.fetch_rss_feed(url)
144+
posts = cls.parse_rss_to_posts(xml_text)
106145
except Exception as e:
107146
verbose_logger.warning(
108147
"LiteLLM: Failed to fetch blog posts from %s: %s. "
@@ -112,10 +151,9 @@ def get_blog_posts(cls, url: str) -> List[Dict[str, str]]:
112151
)
113152
return cls.load_local_blog_posts()
114153

115-
if not cls.validate_blog_posts(data):
154+
if not cls.validate_blog_posts(posts):
116155
return cls.load_local_blog_posts()
117156

118-
posts = data["posts"]
119157
cls._cached_posts = posts
120158
cls._last_fetch_time = now
121159
return posts

litellm/proxy/_types.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4263,7 +4263,7 @@ class DefaultInternalUserParams(LiteLLMPydanticObjectBase):
42634263
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
42644264
]
42654265
] = Field(
4266-
default=LitellmUserRoles.INTERNAL_USER,
4266+
default=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
42674267
description="Default role assigned to new users created",
42684268
)
42694269
max_budget: Optional[float] = Field(

tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,37 @@ def test_get_internal_user_settings(self, mock_proxy_config, mock_auth):
111111
assert "user_role" in data["field_schema"]["properties"]
112112
assert "description" in data["field_schema"]["properties"]["user_role"]
113113

114+
def test_get_internal_user_settings_fresh_db_defaults_to_viewer(
115+
self, mock_auth, monkeypatch
116+
):
117+
"""
118+
On a fresh DB with no saved settings, the GET endpoint should return
119+
INTERNAL_USER_VIEW_ONLY as the default role — matching the runtime
120+
fallback in SSO/SCIM/JWT provisioning paths.
121+
"""
122+
# Simulate fresh DB: no default_internal_user_params in config
123+
empty_config = {
124+
"litellm_settings": {},
125+
"general_settings": {},
126+
"environment_variables": {},
127+
}
128+
129+
from litellm.proxy.proxy_server import proxy_config
130+
131+
async def mock_get_config():
132+
return empty_config
133+
134+
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
135+
136+
response = client.get("/get/internal_user_settings")
137+
assert response.status_code == 200
138+
139+
values = response.json()["values"]
140+
assert values["user_role"] == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, (
141+
f"Fresh DB should default to INTERNAL_USER_VIEW_ONLY, got {values['user_role']}. "
142+
"The Pydantic default must match the runtime fallback."
143+
)
144+
114145
def test_update_internal_user_settings(
115146
self, mock_proxy_config, mock_auth, monkeypatch
116147
):

tests/test_litellm/test_get_blog_posts.py

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
"""Tests for GetBlogPosts utility class."""
2-
import json
32
import time
43
from unittest.mock import MagicMock, patch
54

@@ -13,16 +12,26 @@
1312
get_blog_posts,
1413
)
1514

16-
SAMPLE_RESPONSE = {
17-
"posts": [
18-
{
19-
"title": "Test Post",
20-
"description": "A test post.",
21-
"date": "2026-01-01",
22-
"url": "https://www.litellm.ai/blog/test",
23-
}
24-
]
25-
}
15+
SAMPLE_RSS = """\
16+
<?xml version="1.0" encoding="UTF-8"?>
17+
<rss version="2.0">
18+
<channel>
19+
<title>LiteLLM Blog</title>
20+
<item>
21+
<title>Test Post</title>
22+
<link>https://docs.litellm.ai/blog/test</link>
23+
<description>A test post.</description>
24+
<pubDate>Wed, 01 Jan 2026 10:00:00 GMT</pubDate>
25+
</item>
26+
<item>
27+
<title>Second Post</title>
28+
<link>https://docs.litellm.ai/blog/second</link>
29+
<description>Another post.</description>
30+
<pubDate>Tue, 31 Dec 2025 10:00:00 GMT</pubDate>
31+
</item>
32+
</channel>
33+
</rss>
34+
"""
2635

2736

2837
@pytest.fixture(autouse=True)
@@ -45,26 +54,48 @@ def test_load_local_blog_posts_returns_list():
4554
assert "url" in first
4655

4756

48-
def test_validate_blog_posts_valid():
49-
assert GetBlogPosts.validate_blog_posts(SAMPLE_RESPONSE) is True
57+
def test_parse_rss_to_posts():
58+
posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS, max_posts=1)
59+
assert len(posts) == 1
60+
assert posts[0]["title"] == "Test Post"
61+
assert posts[0]["url"] == "https://docs.litellm.ai/blog/test"
62+
assert posts[0]["description"] == "A test post."
63+
assert posts[0]["date"] == "2026-01-01"
64+
65+
66+
def test_parse_rss_to_posts_multiple():
67+
posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS, max_posts=5)
68+
assert len(posts) == 2
69+
assert posts[1]["title"] == "Second Post"
5070

5171

52-
def test_validate_blog_posts_missing_posts_key():
53-
assert GetBlogPosts.validate_blog_posts({"other": []}) is False
72+
def test_parse_rss_to_posts_invalid_xml():
73+
with pytest.raises(Exception):
74+
GetBlogPosts.parse_rss_to_posts("not xml")
75+
76+
77+
def test_parse_rss_to_posts_missing_channel():
78+
with pytest.raises(ValueError, match="missing <channel>"):
79+
GetBlogPosts.parse_rss_to_posts("<rss></rss>")
80+
81+
82+
def test_validate_blog_posts_valid():
83+
posts = [{"title": "T", "description": "D", "date": "2026-01-01", "url": "https://x.com"}]
84+
assert GetBlogPosts.validate_blog_posts(posts) is True
5485

5586

5687
def test_validate_blog_posts_empty_list():
57-
assert GetBlogPosts.validate_blog_posts({"posts": []}) is False
88+
assert GetBlogPosts.validate_blog_posts([]) is False
5889

5990

60-
def test_validate_blog_posts_not_dict():
61-
assert GetBlogPosts.validate_blog_posts("not a dict") is False
91+
def test_validate_blog_posts_not_list():
92+
assert GetBlogPosts.validate_blog_posts("not a list") is False
6293

6394

6495
def test_get_blog_posts_success():
65-
"""Fetches from remote on first call."""
96+
"""Fetches from RSS on first call."""
6697
mock_response = MagicMock()
67-
mock_response.json.return_value = SAMPLE_RESPONSE
98+
mock_response.text = SAMPLE_RSS
6899
mock_response.raise_for_status = MagicMock()
69100

70101
with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response):
@@ -86,10 +117,10 @@ def test_get_blog_posts_network_error_falls_back_to_local():
86117
assert len(posts) > 0
87118

88119

89-
def test_get_blog_posts_invalid_json_falls_back_to_local():
90-
"""Falls back when remote returns non-dict."""
120+
def test_get_blog_posts_invalid_xml_falls_back_to_local():
121+
"""Falls back when remote returns invalid XML."""
91122
mock_response = MagicMock()
92-
mock_response.json.return_value = "not a dict"
123+
mock_response.text = "not valid xml"
93124
mock_response.raise_for_status = MagicMock()
94125

95126
with patch("litellm.litellm_core_utils.get_blog_posts.httpx.get", return_value=mock_response):
@@ -101,7 +132,8 @@ def test_get_blog_posts_invalid_json_falls_back_to_local():
101132

102133
def test_get_blog_posts_ttl_cache_not_refetched():
103134
"""Within TTL window, does not re-fetch."""
104-
GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"]
135+
cached = [{"title": "Cached", "description": "D", "date": "2026-01-01", "url": "https://x.com"}]
136+
GetBlogPosts._cached_posts = cached
105137
GetBlogPosts._last_fetch_time = time.time() # just now
106138

107139
call_count = 0
@@ -110,7 +142,7 @@ def mock_get(*args, **kwargs):
110142
nonlocal call_count
111143
call_count += 1
112144
m = MagicMock()
113-
m.json.return_value = SAMPLE_RESPONSE
145+
m.text = SAMPLE_RSS
114146
m.raise_for_status = MagicMock()
115147
return m
116148

@@ -123,11 +155,12 @@ def mock_get(*args, **kwargs):
123155

124156
def test_get_blog_posts_ttl_expired_refetches():
125157
"""After TTL window, re-fetches from remote."""
126-
GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"]
158+
cached = [{"title": "Cached", "description": "D", "date": "2026-01-01", "url": "https://x.com"}]
159+
GetBlogPosts._cached_posts = cached
127160
GetBlogPosts._last_fetch_time = time.time() - 7200 # 2 hours ago
128161

129162
mock_response = MagicMock()
130-
mock_response.json.return_value = SAMPLE_RESPONSE
163+
mock_response.text = SAMPLE_RSS
131164
mock_response.raise_for_status = MagicMock()
132165

133166
with patch(

ui/litellm-dashboard/src/components/DefaultUserSettings.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useEffect } from "react";
2-
import { Card, Title, Text, Divider, Button, TextInput } from "@tremor/react";
3-
import { Typography, Spin, Switch, Select, InputNumber } from "antd";
2+
import { Card, Title, Text, Divider, TextInput } from "@tremor/react";
3+
import { Button, Typography, Spin, Switch, Select, InputNumber } from "antd";
44
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
55
import { getInternalUserSettings, updateInternalUserSettings, modelAvailableCall } from "./networking";
66
import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown";
@@ -160,11 +160,10 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
160160
<div className="flex items-center justify-between mb-3">
161161
<Text className="font-medium">Team {index + 1}</Text>
162162
<Button
163-
size="sm"
164-
variant="secondary"
165-
icon={DeleteOutlined}
163+
size="small"
164+
danger
165+
icon={<DeleteOutlined />}
166166
onClick={() => removeTeam(index)}
167-
className="text-red-500 hover:text-red-700"
168167
>
169168
Remove
170169
</Button>
@@ -208,7 +207,7 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
208207
</div>
209208
))}
210209

211-
<Button variant="secondary" icon={PlusOutlined} onClick={addTeam} className="w-full">
210+
<Button icon={<PlusOutlined />} onClick={addTeam} className="w-full">
212211
Add Team
213212
</Button>
214213
</div>
@@ -462,7 +461,6 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
462461
(isEditing ? (
463462
<div className="flex gap-2">
464463
<Button
465-
variant="secondary"
466464
onClick={() => {
467465
setIsEditing(false);
468466
setEditedValues(settings.values || {});
@@ -471,12 +469,12 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
471469
>
472470
Cancel
473471
</Button>
474-
<Button onClick={handleSaveSettings} loading={saving}>
472+
<Button type="primary" onClick={handleSaveSettings} loading={saving}>
475473
Save Changes
476474
</Button>
477475
</div>
478476
) : (
479-
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
477+
<Button type="primary" onClick={() => setIsEditing(true)}>Edit Settings</Button>
480478
))}
481479
</div>
482480

0 commit comments

Comments
 (0)