diff --git a/litellm/__init__.py b/litellm/__init__.py index 299bb18245c..51c66838613 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -358,7 +358,7 @@ ) 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", diff --git a/litellm/litellm_core_utils/get_blog_posts.py b/litellm/litellm_core_utils/get_blog_posts.py index f54deb59290..2f9a14f1279 100644 --- a/litellm/litellm_core_utils/get_blog_posts.py +++ b/litellm/litellm_core_utils/get_blog_posts.py @@ -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 @@ 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 @@ def load_local_blog_posts() -> List[Dict[str, str]]: 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 . + """ + root = ET.fromstring(xml_text) + channel = root.find("channel") + if channel is None: + raise ValueError("RSS feed missing 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 @@ def get_blog_posts(cls, url: str) -> List[Dict[str, str]]: 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 @@ def get_blog_posts(cls, url: str) -> List[Dict[str, str]]: ) 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 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b7ac4212cbd..8bc77d300c6 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4262,7 +4262,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( diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 7378b14cdf5..bd9968ae936 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -111,6 +111,37 @@ def test_get_internal_user_settings(self, mock_proxy_config, mock_auth): 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 ): diff --git a/tests/test_litellm/test_get_blog_posts.py b/tests/test_litellm/test_get_blog_posts.py index a17d78e0bb6..b04fb4ec703 100644 --- a/tests/test_litellm/test_get_blog_posts.py +++ b/tests/test_litellm/test_get_blog_posts.py @@ -1,5 +1,4 @@ """Tests for GetBlogPosts utility class.""" -import json import time from unittest.mock import MagicMock, patch @@ -13,16 +12,26 @@ 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 = """\ + + + + LiteLLM Blog + + Test Post + https://docs.litellm.ai/blog/test + A test post. + Wed, 01 Jan 2026 10:00:00 GMT + + + Second Post + https://docs.litellm.ai/blog/second + Another post. + Tue, 31 Dec 2025 10:00:00 GMT + + + +""" @pytest.fixture(autouse=True) @@ -45,26 +54,48 @@ def test_load_local_blog_posts_returns_list(): assert "url" in first -def test_validate_blog_posts_valid(): - assert GetBlogPosts.validate_blog_posts(SAMPLE_RESPONSE) is True +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_validate_blog_posts_missing_posts_key(): - assert GetBlogPosts.validate_blog_posts({"other": []}) is False +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 "): + GetBlogPosts.parse_rss_to_posts("") + + +def test_validate_blog_posts_valid(): + 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 mock_get(*args, **kwargs): 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 mock_get(*args, **kwargs): 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( diff --git a/ui/litellm-dashboard/src/components/DefaultUserSettings.tsx b/ui/litellm-dashboard/src/components/DefaultUserSettings.tsx index 988a3bcec92..314946c520b 100644 --- a/ui/litellm-dashboard/src/components/DefaultUserSettings.tsx +++ b/ui/litellm-dashboard/src/components/DefaultUserSettings.tsx @@ -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 = ({
Team {index + 1} @@ -208,7 +207,7 @@ const DefaultUserSettings: React.FC = ({
))} - @@ -462,7 +461,6 @@ const DefaultUserSettings: React.FC = ({ (isEditing ? (
-
) : ( - + ))} diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts index 67de4b69174..2ca7ad7ef31 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -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).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"); + }); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts index ebef3da8a77..45bf21a6e7d 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -17,6 +17,49 @@ const extractTeamIdFromApiKeyBreakdown = (apiKeyBreakdown: Record | 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): Record => { + const apiKeys = breakdown.api_keys; + if (!apiKeys || Object.keys(apiKeys).length === 0) return {}; + + const grouped: Record = {}; + + for (const [keyId, keyData] of Object.entries(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): Record => { + const entities = breakdown.entities; + if (entities && Object.keys(entities).length > 0) return entities; + return aggregateApiKeysIntoEntities(breakdown); +}; + export const getEntityBreakdown = ( spendData: EntitySpendData, teamAliasMap: Record = {}, @@ -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;