diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 341a8767db0..79882909c23 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -1,7 +1,7 @@ import asyncio from datetime import datetime from types import SimpleNamespace -from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union +from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, Tuple, Union from fastapi import HTTPException, status @@ -887,8 +887,17 @@ async def get_daily_activity( exclude_entity_ids: Optional[List[str]] = None, metadata_metrics_func: Optional[Callable[[List[Any]], SpendMetrics]] = None, timezone_offset_minutes: Optional[int] = None, + resolve_entity_metadata: Optional[ + Callable[[list[Any]], Awaitable[dict[str, dict]]] + ] = None, ) -> SpendAnalyticsPaginatedResponse: - """Common function to get daily activity for any entity type.""" + """Common function to get daily activity for any entity type. + + ``resolve_entity_metadata`` lets a caller resolve entity metadata from the + rows actually on the page (e.g. user_id -> user_email) instead of fetching + the whole entity table upfront, which matters when the entity set is + unbounded. + """ if prisma_client is None: raise HTTPException( @@ -939,11 +948,18 @@ async def get_daily_activity( take=page_size, ) + resolved_entity_metadata = entity_metadata_field + if resolve_entity_metadata is not None: + resolved_entity_metadata = { + **(entity_metadata_field or {}), + **(await resolve_entity_metadata(daily_spend_data)), + } + aggregated = await _aggregate_spend_records( prisma_client=prisma_client, records=daily_spend_data, entity_id_field=entity_id_field, - entity_metadata_field=entity_metadata_field, + entity_metadata_field=resolved_entity_metadata, ) metadata_metrics = aggregated["totals"] diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index c3ddf4deb1a..6d7f565fb85 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -2617,6 +2617,25 @@ async def ui_view_users( # Using shared metric helper implementations from common_daily_activity +async def _resolve_user_email_metadata( + prisma_client: "PrismaClient", records: list[Any] +) -> dict[str, dict]: + """Map each user_id on the page to its email/alias so the Usage dashboard can + label the 'Spend Per User' chart with the email instead of the raw UUID.""" + user_ids = { + record.user_id for record in records if getattr(record, "user_id", None) + } + if not user_ids: + return {} + users = await UserRepository(prisma_client).table.find_many( + where={"user_id": {"in": list(user_ids)}} + ) + return { + user.user_id: {"user_email": user.user_email, "user_alias": user.user_alias} + for user in users + } + + @router.get( "/user/daily/activity", tags=["Budget & Spend Tracking", "Internal User management"], @@ -2719,6 +2738,9 @@ async def get_user_daily_activity( page=page, page_size=page_size, timezone_offset_minutes=timezone, + resolve_entity_metadata=lambda records: _resolve_user_email_metadata( + prisma_client, records + ), ) except HTTPException: diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index 2d2c18bb46e..b882090e8f1 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -638,6 +638,84 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys(): assert key_data.metrics.spend == 10.0 +def _daily_user_spend_record(*, user_id, api_key, spend): + """A LiteLLM_DailyUserSpend row as the per-user breakdown reads it.""" + return SimpleNamespace( + date="2024-01-01", + user_id=user_id, + api_key=api_key, + model="gpt-4", + model_group="gpt-4", + custom_llm_provider="openai", + mcp_namespaced_tool_name=None, + endpoint="/chat/completions", + spend=spend, + prompt_tokens=10, + completion_tokens=5, + cache_read_input_tokens=0, + cache_creation_input_tokens=0, + api_requests=1, + successful_requests=1, + failed_requests=0, + ) + + +@pytest.mark.asyncio +async def test_get_daily_activity_applies_resolve_entity_metadata_to_breakdown(): + """Regression for LIT-3889: the Spend Per User chart showed raw UUIDs. + + /user/daily/activity used to pass entity_metadata_field=None, so every + user entity in the breakdown carried empty metadata and the dashboard had + nothing to render but the user_id UUID. The page-scoped resolver must put + the resolved email/alias onto the entity metadata so the UI can label it, + while a spender with no email on file still falls back to the raw UUID. + """ + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + + records = [ + _daily_user_spend_record(user_id="user-with-email", api_key="key-1", spend=7.0), + _daily_user_spend_record(user_id="user-no-email", api_key="key-2", spend=3.0), + ] + + mock_table = MagicMock() + mock_table.count = AsyncMock(return_value=len(records)) + mock_table.find_many = AsyncMock(return_value=records) + mock_prisma.db.litellm_dailyuserspend = mock_table + mock_prisma.db.litellm_verificationtoken = MagicMock() + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + + seen_user_ids = {} + + async def resolver(page_records): + seen_user_ids["ids"] = {r.user_id for r in page_records} + return {"user-with-email": {"user_email": "spender@example.com"}} + + result = await get_daily_activity( + prisma_client=mock_prisma, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id=None, + entity_metadata_field=None, + start_date="2024-01-01", + end_date="2024-01-01", + model=None, + api_key=None, + page=1, + page_size=1000, + resolve_entity_metadata=resolver, + ) + + # Resolver is driven by the user_ids actually on the page + assert seen_user_ids["ids"] == {"user-with-email", "user-no-email"} + + entities = result.results[0].breakdown.entities + # Email is on the entity metadata so the UI labels the chart with it + assert entities["user-with-email"].metadata["user_email"] == "spender@example.com" + # No email on file -> empty metadata -> UI falls back to the UUID + assert entities["user-no-email"].metadata == {} + + class TestAdjustDatesForTimezone: """ Regression tests for the timezone double-counting bug. @@ -758,6 +836,8 @@ class TestBuildAggregatedSqlQuery: ] assert "model = $4" in sql assert "api_key = $5" in sql + + @pytest.mark.asyncio async def test_get_daily_activity_aggregated_empty_result_set(): """Regression test for the empty-range 500. diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 9b7fc817e4e..b4602e0ad8b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -2,6 +2,7 @@ import json import os import sys from datetime import datetime, timezone +from types import SimpleNamespace import pytest from fastapi.testclient import TestClient @@ -20,6 +21,7 @@ from litellm.proxy._types import ( ) from litellm.proxy.management_endpoints.internal_user_endpoints import ( LiteLLM_UserTableWithKeyCount, + _resolve_user_email_metadata, _update_internal_user_params, get_user_key_counts, get_users, @@ -3098,3 +3100,56 @@ async def test_ghsa_wvg4_proxy_admin_can_update_user_budget(mocker): user_request=user_request, user_api_key_dict=admin_caller ) assert result is not None + + +@pytest.mark.asyncio +async def test_resolve_user_email_metadata_maps_page_user_ids_to_email(mocker): + """Regression for LIT-3889. + + The Spend Per User chart rendered raw UUIDs because the per-user activity + breakdown carried no email. This resolver must turn the user_ids on the + page into {user_id: {user_email, user_alias}} so the chart can label each + spender, and it must only look up the user_ids actually present (not the + whole user table). + """ + + mock_prisma_client = mocker.MagicMock() + find_many = mocker.AsyncMock( + return_value=[ + SimpleNamespace( + user_id="u1", user_email="alice@example.com", user_alias="Alice" + ), + SimpleNamespace(user_id="u2", user_email=None, user_alias="bob-alias"), + ] + ) + mock_prisma_client.db.litellm_usertable.find_many = find_many + + records = [ + SimpleNamespace(user_id="u1"), + SimpleNamespace(user_id="u1"), # duplicate -> deduped + SimpleNamespace(user_id="u2"), + ] + + result = await _resolve_user_email_metadata(mock_prisma_client, records) + + assert result == { + "u1": {"user_email": "alice@example.com", "user_alias": "Alice"}, + "u2": {"user_email": None, "user_alias": "bob-alias"}, + } + where_arg = find_many.call_args.kwargs["where"] + assert set(where_arg["user_id"]["in"]) == {"u1", "u2"} + + +@pytest.mark.asyncio +async def test_resolve_user_email_metadata_skips_db_when_no_user_ids(mocker): + """No user_ids on the page (e.g. all spend is unattributed) means no query.""" + mock_prisma_client = mocker.MagicMock() + find_many = mocker.AsyncMock(return_value=[]) + mock_prisma_client.db.litellm_usertable.find_many = find_many + + records = [SimpleNamespace(user_id=None), SimpleNamespace(user_id="")] + + result = await _resolve_user_email_metadata(mock_prisma_client, records) + + assert result == {} + find_many.assert_not_called() diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx index dc201cccfea..15f572717e7 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.test.tsx @@ -703,4 +703,39 @@ describe("EntityUsage", () => { expect(screen.getByText("tag-1")).toBeInTheDocument(); }); }); + + it("should label the chart with user_email metadata instead of the raw UUID (LIT-3889)", async () => { + const userUuid = "c0e68be8-057e-4e2f-9d3a-000000000000"; + const spendDataForUser = { + ...mockSpendData, + results: [ + { + ...mockSpendData.results[0], + breakdown: { + ...mockSpendData.results[0].breakdown, + entities: { + [userUuid]: { + ...mockSpendData.results[0].breakdown.entities["tag-1"], + metadata: { user_email: "spender@example.com" }, + }, + }, + }, + }, + ], + }; + + mockUserDailyActivityCall.mockResolvedValue(spendDataForUser); + + // entityList is null to simulate a spender missing from the paginated user list + render(); + + await waitFor(() => { + expect(mockUserDailyActivityCall).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.getByText("spender@example.com")).toBeInTheDocument(); + }); + expect(screen.queryByText(userUuid)).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index d415b8453a2..d78e39a87f3 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -328,6 +328,14 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti if (metadata?.team_alias) { return metadata.team_alias; } + // Resolve user_id to email/alias so the Spend Per User chart never shows a raw UUID + // when an email is on file (the entityList is paginated and may miss spenders) + if (metadata?.user_email) { + return metadata.user_email; + } + if (metadata?.user_alias) { + return metadata.user_alias; + } return entity; };