Merge pull request #23822 from BerriAI/litellm_ryan_march_16

Litellm ryan's daily branch march 16
This commit is contained in:
ryan-crabbe 2026-03-17 10:03:01 -07:00 committed by GitHub
commit ef9cc33ee3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 345 additions and 68 deletions

View file

@ -358,7 +358,7 @@ model_cost_map_url: str = os.getenv(
)
blog_posts_url: str = os.getenv(
"LITELLM_BLOG_POSTS_URL",
"https://raw.githubusercontent.com/BerriAI/litellm/main/litellm/blog_posts.json",
"https://docs.litellm.ai/blog/rss.xml",
)
anthropic_beta_headers_url: str = os.getenv(
"LITELLM_ANTHROPIC_BETA_HEADERS_URL",

View file

@ -1,8 +1,8 @@
"""
Pulls the latest LiteLLM blog posts from GitHub.
Pulls the latest LiteLLM blog posts from the docs RSS feed.
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).
RSS 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
@ -11,8 +11,10 @@ Disable remote fetching entirely:
import json
import os
import time
import xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from importlib.resources import files
from typing import Any, Dict, List, Optional
from typing import Dict, List, Optional
import httpx
from pydantic import BaseModel
@ -37,9 +39,8 @@ 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
- Fetches RSS feed from docs site with a 5-second timeout
- Parses the XML and extracts the latest blog post
- Caches the result in-process for BLOG_POSTS_TTL_SECONDS (1 hour)
- Falls back to the bundled local backup on any failure
"""
@ -56,30 +57,67 @@ class GetBlogPosts:
return content.get("posts", [])
@staticmethod
def fetch_remote_blog_posts(url: str, timeout: int = 5) -> dict:
def fetch_rss_feed(url: str, timeout: int = 5) -> str:
"""
Fetch blog posts JSON from a remote URL.
Fetch RSS XML from a remote URL.
Returns the parsed response. Raises on network/parse errors.
Returns the raw XML text. Raises on network errors.
"""
response = httpx.get(url, timeout=timeout)
response.raise_for_status()
return response.json()
return response.text
@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__,
def parse_rss_to_posts(xml_text: str, max_posts: int = 1) -> List[Dict[str, str]]:
"""
Parse RSS XML and return a list of blog post dicts.
Extracts title, description, date (YYYY-MM-DD), and url from each <item>.
"""
root = ET.fromstring(xml_text)
channel = root.find("channel")
if channel is None:
raise ValueError("RSS feed missing <channel> element")
posts: List[Dict[str, str]] = []
for item in channel.findall("item"):
if len(posts) >= max_posts:
break
title_el = item.find("title")
link_el = item.find("link")
desc_el = item.find("description")
pub_date_el = item.find("pubDate")
if title_el is None or link_el is None:
continue
# Parse RFC 2822 date to YYYY-MM-DD
date_str = ""
if pub_date_el is not None and pub_date_el.text:
try:
dt = parsedate_to_datetime(pub_date_el.text)
date_str = dt.strftime("%Y-%m-%d")
except Exception:
date_str = pub_date_el.text
posts.append(
{
"title": title_el.text or "",
"description": desc_el.text or "" if desc_el is not None else "",
"date": date_str,
"url": link_el.text or "",
}
)
return False
posts = data.get("posts")
return posts
@staticmethod
def validate_blog_posts(posts: List[Dict[str, str]]) -> bool:
"""Return True if posts is a non-empty list."""
if not isinstance(posts, list) or len(posts) == 0:
verbose_logger.warning(
"LiteLLM: Blog posts response has no valid 'posts' list. "
"LiteLLM: Parsed RSS feed has no valid posts. "
"Falling back to local backup.",
)
return False
@ -102,7 +140,8 @@ class GetBlogPosts:
return cached
try:
data = cls.fetch_remote_blog_posts(url)
xml_text = cls.fetch_rss_feed(url)
posts = cls.parse_rss_to_posts(xml_text)
except Exception as e:
verbose_logger.warning(
"LiteLLM: Failed to fetch blog posts from %s: %s. "
@ -112,10 +151,9 @@ class GetBlogPosts:
)
return cls.load_local_blog_posts()
if not cls.validate_blog_posts(data):
if not cls.validate_blog_posts(posts):
return cls.load_local_blog_posts()
posts = data["posts"]
cls._cached_posts = posts
cls._last_fetch_time = now
return posts

View file

@ -4263,7 +4263,7 @@ class DefaultInternalUserParams(LiteLLMPydanticObjectBase):
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
]
] = Field(
default=LitellmUserRoles.INTERNAL_USER,
default=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
description="Default role assigned to new users created",
)
max_budget: Optional[float] = Field(

View file

@ -111,6 +111,37 @@ class TestProxySettingEndpoints:
assert "user_role" in data["field_schema"]["properties"]
assert "description" in data["field_schema"]["properties"]["user_role"]
def test_get_internal_user_settings_fresh_db_defaults_to_viewer(
self, mock_auth, monkeypatch
):
"""
On a fresh DB with no saved settings, the GET endpoint should return
INTERNAL_USER_VIEW_ONLY as the default role matching the runtime
fallback in SSO/SCIM/JWT provisioning paths.
"""
# Simulate fresh DB: no default_internal_user_params in config
empty_config = {
"litellm_settings": {},
"general_settings": {},
"environment_variables": {},
}
from litellm.proxy.proxy_server import proxy_config
async def mock_get_config():
return empty_config
monkeypatch.setattr(proxy_config, "get_config", mock_get_config)
response = client.get("/get/internal_user_settings")
assert response.status_code == 200
values = response.json()["values"]
assert values["user_role"] == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, (
f"Fresh DB should default to INTERNAL_USER_VIEW_ONLY, got {values['user_role']}. "
"The Pydantic default must match the runtime fallback."
)
def test_update_internal_user_settings(
self, mock_proxy_config, mock_auth, monkeypatch
):

View file

@ -1,5 +1,4 @@
"""Tests for GetBlogPosts utility class."""
import json
import time
from unittest.mock import MagicMock, patch
@ -13,16 +12,26 @@ from litellm.litellm_core_utils.get_blog_posts import (
get_blog_posts,
)
SAMPLE_RESPONSE = {
"posts": [
{
"title": "Test Post",
"description": "A test post.",
"date": "2026-01-01",
"url": "https://www.litellm.ai/blog/test",
}
]
}
SAMPLE_RSS = """\
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>LiteLLM Blog</title>
<item>
<title>Test Post</title>
<link>https://docs.litellm.ai/blog/test</link>
<description>A test post.</description>
<pubDate>Wed, 01 Jan 2026 10:00:00 GMT</pubDate>
</item>
<item>
<title>Second Post</title>
<link>https://docs.litellm.ai/blog/second</link>
<description>Another post.</description>
<pubDate>Tue, 31 Dec 2025 10:00:00 GMT</pubDate>
</item>
</channel>
</rss>
"""
@pytest.fixture(autouse=True)
@ -45,26 +54,48 @@ def test_load_local_blog_posts_returns_list():
assert "url" in first
def test_parse_rss_to_posts():
posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS, max_posts=1)
assert len(posts) == 1
assert posts[0]["title"] == "Test Post"
assert posts[0]["url"] == "https://docs.litellm.ai/blog/test"
assert posts[0]["description"] == "A test post."
assert posts[0]["date"] == "2026-01-01"
def test_parse_rss_to_posts_multiple():
posts = GetBlogPosts.parse_rss_to_posts(SAMPLE_RSS, max_posts=5)
assert len(posts) == 2
assert posts[1]["title"] == "Second Post"
def test_parse_rss_to_posts_invalid_xml():
with pytest.raises(Exception):
GetBlogPosts.parse_rss_to_posts("not xml")
def test_parse_rss_to_posts_missing_channel():
with pytest.raises(ValueError, match="missing <channel>"):
GetBlogPosts.parse_rss_to_posts("<rss></rss>")
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
posts = [{"title": "T", "description": "D", "date": "2026-01-01", "url": "https://x.com"}]
assert GetBlogPosts.validate_blog_posts(posts) is True
def test_validate_blog_posts_empty_list():
assert GetBlogPosts.validate_blog_posts({"posts": []}) is False
assert GetBlogPosts.validate_blog_posts([]) is False
def test_validate_blog_posts_not_dict():
assert GetBlogPosts.validate_blog_posts("not a dict") is False
def test_validate_blog_posts_not_list():
assert GetBlogPosts.validate_blog_posts("not a list") is False
def test_get_blog_posts_success():
"""Fetches from remote on first call."""
"""Fetches from RSS on first call."""
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_RESPONSE
mock_response.text = SAMPLE_RSS
mock_response.raise_for_status = MagicMock()
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():
assert len(posts) > 0
def test_get_blog_posts_invalid_json_falls_back_to_local():
"""Falls back when remote returns non-dict."""
def test_get_blog_posts_invalid_xml_falls_back_to_local():
"""Falls back when remote returns invalid XML."""
mock_response = MagicMock()
mock_response.json.return_value = "not a dict"
mock_response.text = "not valid xml"
mock_response.raise_for_status = MagicMock()
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():
def test_get_blog_posts_ttl_cache_not_refetched():
"""Within TTL window, does not re-fetch."""
GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"]
cached = [{"title": "Cached", "description": "D", "date": "2026-01-01", "url": "https://x.com"}]
GetBlogPosts._cached_posts = cached
GetBlogPosts._last_fetch_time = time.time() # just now
call_count = 0
@ -110,7 +142,7 @@ def test_get_blog_posts_ttl_cache_not_refetched():
nonlocal call_count
call_count += 1
m = MagicMock()
m.json.return_value = SAMPLE_RESPONSE
m.text = SAMPLE_RSS
m.raise_for_status = MagicMock()
return m
@ -123,11 +155,12 @@ def test_get_blog_posts_ttl_cache_not_refetched():
def test_get_blog_posts_ttl_expired_refetches():
"""After TTL window, re-fetches from remote."""
GetBlogPosts._cached_posts = SAMPLE_RESPONSE["posts"]
cached = [{"title": "Cached", "description": "D", "date": "2026-01-01", "url": "https://x.com"}]
GetBlogPosts._cached_posts = cached
GetBlogPosts._last_fetch_time = time.time() - 7200 # 2 hours ago
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_RESPONSE
mock_response.text = SAMPLE_RSS
mock_response.raise_for_status = MagicMock()
with patch(

View file

@ -1,6 +1,6 @@
import React, { useState, useEffect } from "react";
import { Card, Title, Text, Divider, Button, TextInput } from "@tremor/react";
import { Typography, Spin, Switch, Select, InputNumber } from "antd";
import { Card, Title, Text, Divider, TextInput } from "@tremor/react";
import { Button, Typography, Spin, Switch, Select, InputNumber } from "antd";
import { PlusOutlined, DeleteOutlined } from "@ant-design/icons";
import { getInternalUserSettings, updateInternalUserSettings, modelAvailableCall } from "./networking";
import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown";
@ -160,11 +160,10 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
<div className="flex items-center justify-between mb-3">
<Text className="font-medium">Team {index + 1}</Text>
<Button
size="sm"
variant="secondary"
icon={DeleteOutlined}
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => removeTeam(index)}
className="text-red-500 hover:text-red-700"
>
Remove
</Button>
@ -208,7 +207,7 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
</div>
))}
<Button variant="secondary" icon={PlusOutlined} onClick={addTeam} className="w-full">
<Button icon={<PlusOutlined />} onClick={addTeam} className="w-full">
Add Team
</Button>
</div>
@ -462,7 +461,6 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
(isEditing ? (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={() => {
setIsEditing(false);
setEditedValues(settings.values || {});
@ -471,12 +469,12 @@ const DefaultUserSettings: React.FC<DefaultUserSettingsProps> = ({
>
Cancel
</Button>
<Button onClick={handleSaveSettings} loading={saving}>
<Button type="primary" onClick={handleSaveSettings} loading={saving}>
Save Changes
</Button>
</div>
) : (
<Button onClick={() => setIsEditing(true)}>Edit Settings</Button>
<Button type="primary" onClick={() => setIsEditing(true)}>Edit Settings</Button>
))}
</div>

View file

@ -11,6 +11,7 @@ import {
getEntityBreakdown,
handleExportCSV,
handleExportJSON,
resolveEntities,
} from "./utils";
vi.mock("@/utils/dataUtils", () => ({
@ -1561,4 +1562,137 @@ describe("EntityUsageExport utils", () => {
window.Blob = originalBlob;
});
});
describe("resolveEntities and aggregated endpoint fallback", () => {
// Simulates the response from /user/daily/activity/aggregated which has
// empty entities but populated api_keys at the breakdown level.
// Derived from mockSpendData: flatten all entities' api_key_breakdowns
// into top-level api_keys, clear entities, and add a second key for team-1
// to test multi-key grouping.
const aggregatedSpendData: EntitySpendData = {
...mockSpendData,
results: mockSpendData.results.slice(0, 1).map((day) => ({
...day,
breakdown: {
entities: {},
api_keys: {
...Object.fromEntries(
Object.values(day.breakdown.entities as Record<string, any>).flatMap((e: any) =>
Object.entries(e.api_key_breakdown || {}),
),
),
// Extra key on team-1 to test multi-key-per-team aggregation
key1b: {
metrics: { spend: 5, api_requests: 50, successful_requests: 48, failed_requests: 2, total_tokens: 500 },
metadata: { team_id: "team-1", key_alias: "staging-key" },
},
},
models: { "gpt-4": { metrics: { spend: 35, api_requests: 350, total_tokens: 3500 } } },
},
})),
};
describe("resolveEntities", () => {
it("should return entities when populated", () => {
const breakdown = {
entities: { e1: { metrics: { spend: 1 } } },
api_keys: { k1: { metrics: { spend: 2 }, metadata: { team_id: "t1" } } },
};
const result = resolveEntities(breakdown);
expect(result).toBe(breakdown.entities);
});
it("should aggregate api_keys into entities when entities is empty", () => {
const breakdown = aggregatedSpendData.results[0].breakdown;
const result = resolveEntities(breakdown);
// Two teams: team-1 (key1+key2) and team-2 (key3)
expect(Object.keys(result)).toHaveLength(2);
expect(result["team-1"]).toBeDefined();
expect(result["team-2"]).toBeDefined();
// team-1 spend = 10.5 (key1) + 5 (key1b)
expect(result["team-1"].metrics.spend).toBe(15.5);
expect(result["team-1"].metrics.api_requests).toBe(150);
expect(result["team-1"].metrics.total_tokens).toBe(1500);
// team-2 spend = 20.3 (key2)
expect(result["team-2"].metrics.spend).toBe(20.3);
expect(result["team-2"].metrics.api_requests).toBe(200);
});
it("should use 'Unassigned' for keys without team_id", () => {
const breakdown = {
entities: {},
api_keys: {
k1: {
metrics: { spend: 7, api_requests: 10, successful_requests: 10, failed_requests: 0, total_tokens: 100 },
metadata: {},
},
},
};
const result = resolveEntities(breakdown);
expect(result["Unassigned"]).toBeDefined();
expect(result["Unassigned"].metrics.spend).toBe(7);
});
it("should handle missing or empty api_keys gracefully", () => {
expect(Object.keys(resolveEntities({ entities: {}, api_keys: {} }))).toHaveLength(0);
expect(Object.keys(resolveEntities({ entities: {} }))).toHaveLength(0);
});
it("should preserve api_key_breakdown on aggregated entities", () => {
const breakdown = aggregatedSpendData.results[0].breakdown;
const result = resolveEntities(breakdown);
// team-1 should have key1 and key1b in api_key_breakdown
expect(Object.keys(result["team-1"].api_key_breakdown)).toEqual(["key1", "key1b"]);
// team-2 should have key2
expect(Object.keys(result["team-2"].api_key_breakdown)).toEqual(["key2"]);
});
});
describe("getEntityBreakdown with aggregated data", () => {
it("should produce breakdown from api_keys when entities is empty", () => {
const result = getEntityBreakdown(aggregatedSpendData);
expect(result.length).toBeGreaterThan(0);
// Sorted by spend desc: team-2 (20.3) then team-1 (15.5)
expect(result[0].metrics.spend).toBe(20.3);
expect(result[1].metrics.spend).toBe(15.5);
});
});
describe("generateDailyData with aggregated data", () => {
it("should produce rows from api_keys when entities is empty", () => {
const result = generateDailyData(aggregatedSpendData, "Team");
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty("Date");
expect(result[0]).toHaveProperty("Team");
});
});
describe("generateDailyWithKeysData with aggregated data", () => {
it("should produce rows from api_keys when entities is empty", () => {
const result = generateDailyWithKeysData(aggregatedSpendData, "Team");
expect(result.length).toBeGreaterThan(0);
// Should have 3 key rows (key1, key1b, key2)
expect(result).toHaveLength(3);
const keyIds = result.map((r) => r["Key ID"]);
expect(keyIds).toContain("key1");
expect(keyIds).toContain("key1b");
expect(keyIds).toContain("key2");
});
});
describe("generateDailyWithModelsData with aggregated data", () => {
it("should produce rows from api_keys when entities is empty", () => {
const result = generateDailyWithModelsData(aggregatedSpendData, "Team");
expect(result.length).toBeGreaterThan(0);
expect(result[0]).toHaveProperty("Model");
});
});
});
});

View file

@ -17,6 +17,49 @@ const extractTeamIdFromApiKeyBreakdown = (apiKeyBreakdown: Record<string, any> |
return null;
};
// Mirrors backend SpendMetrics fields (litellm/types/activity_tracking.py).
// If the backend adds a field, add it here too.
const METRIC_KEYS = [
"spend", "api_requests", "successful_requests", "failed_requests",
"total_tokens", "prompt_tokens", "completion_tokens",
"cache_read_input_tokens", "cache_creation_input_tokens",
] as const;
// When breakdown.entities is empty (aggregated endpoint), reconstruct entities
// from breakdown.api_keys by grouping on metadata.team_id.
const aggregateApiKeysIntoEntities = (breakdown: Record<string, any>): Record<string, any> => {
const apiKeys = breakdown.api_keys;
if (!apiKeys || Object.keys(apiKeys).length === 0) return {};
const grouped: Record<string, any> = {};
for (const [keyId, keyData] of Object.entries<any>(apiKeys)) {
const teamId = keyData?.metadata?.team_id || "Unassigned";
if (!grouped[teamId]) {
grouped[teamId] = {
metrics: Object.fromEntries(METRIC_KEYS.map((k) => [k, 0])),
api_key_breakdown: {},
};
}
const m = grouped[teamId].metrics;
const km = keyData?.metrics || {};
for (const k of METRIC_KEYS) {
m[k] += km[k] || 0;
}
grouped[teamId].api_key_breakdown[keyId] = keyData;
}
return grouped;
};
// Returns breakdown.entities if populated, otherwise falls back to
// reconstructing entities from breakdown.api_keys.
export const resolveEntities = (breakdown: Record<string, any>): Record<string, any> => {
const entities = breakdown.entities;
if (entities && Object.keys(entities).length > 0) return entities;
return aggregateApiKeysIntoEntities(breakdown);
};
export const getEntityBreakdown = (
spendData: EntitySpendData,
teamAliasMap: Record<string, string> = {},
@ -24,7 +67,7 @@ export const getEntityBreakdown = (
const entitySpend: { [key: string]: EntityBreakdown } = {};
spendData.results.forEach((day) => {
Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => {
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
// Extract team_id from api_key_breakdown metadata (not data.metadata which is empty)
const teamId = extractTeamIdFromApiKeyBreakdown(data.api_key_breakdown) || entity;
// Extract key_alias from the first API key that has one
@ -80,7 +123,7 @@ export const generateDailyData = (
const dailyBreakdown: any[] = [];
spendData.results.forEach((day) => {
Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => {
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
// Extract team_id from api_key_breakdown metadata (not data.metadata which is empty)
const teamId = extractTeamIdFromApiKeyBreakdown(data.api_key_breakdown);
const teamAlias = teamId ? teamAliasMap[teamId] || null : null;
@ -129,7 +172,7 @@ export const generateDailyWithKeysData = (
} = {};
spendData.results.forEach((day) => {
Object.entries(day.breakdown.entities || {}).forEach(([entity, data]: [string, any]) => {
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
const apiKeyBreakdown = data.api_key_breakdown || {};
// Iterate through each API key in the breakdown
@ -202,7 +245,7 @@ export const generateDailyWithModelsData = (
spendData.results.forEach((day) => {
const dailyEntityModels: { [key: string]: { [key: string]: any } } = {};
Object.entries(day.breakdown.entities || {}).forEach(([entity, entityData]: [string, any]) => {
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, entityData]: [string, any]) => {
if (!dailyEntityModels[entity]) {
dailyEntityModels[entity] = {};
}
@ -230,7 +273,7 @@ export const generateDailyWithModelsData = (
});
Object.entries(dailyEntityModels).forEach(([entity, models]) => {
const entityData = day.breakdown.entities?.[entity];
const entityData = resolveEntities(day.breakdown)[entity];
// Extract team_id from api_key_breakdown metadata (not entityData.metadata which is empty)
const teamId = extractTeamIdFromApiKeyBreakdown(entityData?.api_key_breakdown);
const teamAlias = teamId ? teamAliasMap[teamId] || null : null;