mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
Merge pull request #23822 from BerriAI/litellm_ryan_march_16
Litellm ryan's daily branch march 16
This commit is contained in:
commit
ef9cc33ee3
8 changed files with 345 additions and 68 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue