diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 54574ed64e3..8199f7194a5 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -843,6 +843,7 @@ class LiteLLMRoutes(enum.Enum): # Tag usage endpoints scope internal users to tags produced by # their own keys in tag_management_endpoints.py. "/tag/daily/activity", + "/tag/daily/activity/aggregated", "/tag/list", "/v1/models/{model_id}", "/models/{model_id}", @@ -867,6 +868,7 @@ class LiteLLMRoutes(enum.Enum): # Tag usage endpoints scope internal viewers to tags produced by # their own keys in tag_management_endpoints.py. "/tag/daily/activity", + "/tag/daily/activity/aggregated", "/tag/list", ] ) @@ -985,6 +987,7 @@ class LiteLLMRoutes(enum.Enum): "/team/daily/activity", "/team/daily/activity/aggregated", "/tag/daily/activity", + "/tag/daily/activity/aggregated", "/tag/list", "/audit", "/audit/{id}", diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index bf8e7bc15fb..62e42ef46b5 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -560,6 +560,26 @@ async def get_api_key_metadata( return await attach_user_emails(prisma_client, combined) +MAX_AGGREGATED_RANGE_DAYS: Final = 400 + + +def aggregated_date_range_error(start_date: str | None, end_date: str | None) -> str | None: + """The aggregated endpoints have no pagination to bound their work, so malformed + dates and ranges wider than the UI ever requests are rejected before querying.""" + if start_date is None or end_date is None: + return "Please provide start_date and end_date" + try: + parsed_start: Final = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + parsed_end: Final = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError: + return "start_date and end_date must be valid YYYY-MM-DD dates" + if parsed_end < parsed_start: + return "end_date must be on or after start_date" + if (parsed_end - parsed_start).days > MAX_AGGREGATED_RANGE_DAYS: + return f"Date range must be at most {MAX_AGGREGATED_RANGE_DAYS} days" + return None + + def _adjust_dates_for_timezone( start_date: str, end_date: str, diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index ab33d4bd766..20ae2b54e80 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -27,7 +27,9 @@ from litellm.proxy.common_utils.user_api_key_cache import ( ) from litellm.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, + aggregated_date_range_error, get_daily_activity, + get_daily_activity_aggregated, ) from litellm.proxy.management_helpers.utils import handle_budget_for_entity from litellm.repositories.model_repository import ModelRepository @@ -57,6 +59,7 @@ if TYPE_CHECKING: from litellm.types.router import Deployment router: Final = APIRouter() +_TAG_MANAGEMENT_TAGS: Final = ["tag management"] class _TagRecord(Protocol): @@ -245,7 +248,7 @@ async def get_deployments_by_model(model: str, llm_router: "Router") -> list["De @router.post( "/tag/new", - tags=["tag management"], + tags=_TAG_MANAGEMENT_TAGS, dependencies=[Depends(user_api_key_auth)], ) async def new_tag( @@ -393,7 +396,7 @@ async def _add_tag_to_deployment(deployment: "Deployment", tag: str): @router.post( "/tag/update", - tags=["tag management"], + tags=_TAG_MANAGEMENT_TAGS, dependencies=[Depends(user_api_key_auth)], ) async def update_tag( @@ -485,7 +488,7 @@ async def update_tag( @router.post( "/tag/info", - tags=["tag management"], + tags=_TAG_MANAGEMENT_TAGS, dependencies=[Depends(user_api_key_auth)], ) async def info_tag( @@ -574,7 +577,7 @@ def _validate_tag_list_date_range(start_date: str | None, end_date: str | None) @router.get( "/tag/list", - tags=["tag management"], + tags=_TAG_MANAGEMENT_TAGS, dependencies=[Depends(user_api_key_auth)], ) async def list_tags( @@ -685,7 +688,7 @@ async def list_tags( @router.post( "/tag/delete", - tags=["tag management"], + tags=_TAG_MANAGEMENT_TAGS, dependencies=[Depends(user_api_key_auth)], ) async def delete_tag( @@ -722,7 +725,7 @@ async def delete_tag( @router.get( "/tag/daily/activity", response_model=SpendAnalyticsPaginatedResponse, - tags=["tag management"], + tags=_TAG_MANAGEMENT_TAGS, dependencies=[Depends(user_api_key_auth)], ) async def get_tag_daily_activity( @@ -786,3 +789,69 @@ async def get_tag_daily_activity( # individual tags, making this trade-off acceptable. metadata_metrics_func=None, ) + + +@router.get( + "/tag/daily/activity/aggregated", + response_model=SpendAnalyticsPaginatedResponse, + tags=_TAG_MANAGEMENT_TAGS, +) +async def get_tag_daily_activity_aggregated( + tags: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + model: str | None = None, + api_key: str | None = None, + timezone: int | None = None, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Daily activity for tags over the whole range in one response, with per-tag breakdown. + + One SQL GROUPING SETS pass reads the range from a single snapshot, so callers never + walk pages of LiteLLM_DailyTagSpend and never re-count rows that the tag spend + scheduler shifted across a page boundary mid-walk. Same response shape as the + paginated endpoint with page metadata pinned to a single page. + + Args: + tags (Optional[str]): Comma-separated list of tags to filter by. If not provided, returns data for all tags. + start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD). + end_date (Optional[str]): End date for the activity period (YYYY-MM-DD). + model (Optional[str]): Filter by model name. + api_key (Optional[str]): Filter by API key. + timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset() convention. + + Returns: + SpendAnalyticsPaginatedResponse: Response containing all daily activity data for the range. + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException(status_code=500, detail="Database not connected") + + range_error: Final = aggregated_date_range_error(start_date, end_date) + if range_error is not None: + raise HTTPException(status_code=400, detail=range_error) + + tag_list: Final = tags.split(",") if tags else None + scoped_api_key_filter: Final = await _get_tag_daily_activity_api_key_filter( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + requested_api_key=api_key, + ) + if scoped_api_key_filter == []: + return SpendAnalyticsPaginatedResponse(results=[]) + + return await get_daily_activity_aggregated( + prisma_client=prisma_client, + table_name="litellm_dailytagspend", + entity_id_field="tag", + entity_id=tag_list, + entity_metadata_field=None, + start_date=start_date, + end_date=end_date, + model=model, + api_key=scoped_api_key_filter, + timezone_offset_minutes=timezone, + include_entity_breakdown=True, + ) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index d092fc2fbb7..1d81892f64a 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -124,6 +124,7 @@ from litellm.proxy.hooks.model_max_budget_limiter import ( resolve_model_budget, ) from litellm.proxy.management_endpoints.common_daily_activity import ( + aggregated_date_range_error, get_daily_activity_aggregated, ) from litellm.proxy.management_endpoints.common_utils import ( @@ -6701,26 +6702,6 @@ async def get_team_daily_activity( ) -_MAX_AGGREGATED_RANGE_DAYS: Final = 400 - - -def _aggregated_date_range_error(start_date: str | None, end_date: str | None) -> str | None: - """The aggregated endpoint has no pagination to bound its work, so malformed - dates and ranges wider than the UI ever requests are rejected before querying.""" - if start_date is None or end_date is None: - return "Please provide start_date and end_date" - try: - parsed_start: Final = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) - parsed_end: Final = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) - except ValueError: - return "start_date and end_date must be valid YYYY-MM-DD dates" - if parsed_end < parsed_start: - return "end_date must be on or after start_date" - if (parsed_end - parsed_start).days > _MAX_AGGREGATED_RANGE_DAYS: - return f"Date range must be at most {_MAX_AGGREGATED_RANGE_DAYS} days" - return None - - @router.get( "/team/daily/activity/aggregated", response_model=SpendAnalyticsPaginatedResponse, @@ -6763,7 +6744,7 @@ async def get_team_daily_activity_aggregated( if prisma_client is None: raise _daily_activity_error(status_code=500, message=CommonProxyErrors.db_not_connected_error.value) - range_error: Final = _aggregated_date_range_error(start_date, end_date) + range_error: Final = aggregated_date_range_error(start_date, end_date) if range_error is not None: raise _daily_activity_error(status_code=400, message=range_error) diff --git a/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py index 3cfdd345a45..3f8615a814b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_tag_management_endpoints.py @@ -1513,3 +1513,162 @@ async def test_add_tag_to_deployment_model_not_found(): assert exc_info.value.status_code == 500 assert "not found in database" in str(exc_info.value.detail) + + +def _grouping_set_row(*, group_level: int, date: str | None = None, spend: float = 0.0, **overrides: object) -> dict: + """One already-aggregated rollup row as ``LiteLLM_DailyTagSpend``'s GROUPING SETS query emits it.""" + return { + "date": date, + "api_key": None, + "model": None, + "model_group": None, + "custom_llm_provider": None, + "mcp_namespaced_tool_name": None, + "endpoint": None, + "group_level": group_level, + "spend": spend, + "ptu_flat_cost": 0.0, + "prompt_tokens": 0, + "completion_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "autorouter_savings_spend": 0.0, + "api_requests": 0, + "successful_requests": 0, + "failed_requests": 0, + **overrides, + } + + +@pytest.mark.asyncio +async def test_tag_daily_activity_aggregated_returns_range_totals_without_paginating(): + """Regression for #37434. + + ``/tag/daily/activity`` paginates raw ``LiteLLM_DailyTagSpend`` rows, so a caller + after range totals has to walk every page and add them up. The tag spend scheduler + inserts rows while that walk is in flight, shifting rows past the caller's offset so + they get served twice and the summed total drifts upward between identical reads. + The aggregated endpoint sums in the database against a single snapshot, so its + ``metadata`` already carries the whole range and there are no pages to reassemble. + """ + from litellm.proxy.management_endpoints.tag_management_endpoints import ( + get_tag_daily_activity_aggregated, + ) + + mock_user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + + rollup_rows = [ + _grouping_set_row(group_level=127, spend=7.5, api_requests=3), + _grouping_set_row(group_level=63, date="2025-01-01", spend=5.0, api_requests=2), + _grouping_set_row(group_level=63, date="2025-01-02", spend=2.5, api_requests=1), + ] + entity_rows = [ + {**_grouping_set_row(group_level=0, date="2025-01-01", spend=5.0), "entity_id": "prod", "api_key_rolled": 1}, + {**_grouping_set_row(group_level=0, date="2025-01-02", spend=2.5), "entity_id": "prod", "api_key_rolled": 1}, + ] + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + mock_db = Mock() + mock_prisma.db = mock_db + mock_db.query_raw = AsyncMock(side_effect=[rollup_rows, entity_rows]) + + result = await get_tag_daily_activity_aggregated( + tags="prod", + start_date="2025-01-01", + end_date="2025-01-02", + user_api_key_dict=mock_user_auth, + ) + + mock_db.litellm_dailytagspend.find_many.assert_not_called() + mock_db.litellm_dailytagspend.count.assert_not_called() + + queries = [call.args[0] for call in mock_db.query_raw.call_args_list] + assert len(queries) == 2 + assert all('"LiteLLM_DailyTagSpend"' in query for query in queries) + assert all("LIMIT" not in query and "OFFSET" not in query for query in queries) + + assert result.metadata.total_spend == 7.5 + assert result.metadata.total_api_requests == 3 + assert result.metadata.page == 1 + assert result.metadata.total_pages == 1 + assert result.metadata.has_more is False + assert [day.date.isoformat() for day in result.results] == ["2025-01-02", "2025-01-01"] + assert result.results[0].breakdown.entities["prod"].metrics.spend == 2.5 + + +@pytest.mark.asyncio +async def test_internal_user_tag_daily_activity_aggregated_is_scoped_to_their_keys(): + """The aggregated variant must apply the same per-key scoping as the paginated one, + so an internal user cannot read proxy-wide tag spend through the new route.""" + from litellm.proxy.management_endpoints.tag_management_endpoints import ( + get_tag_daily_activity_aggregated, + ) + + mock_user_auth = UserAPIKeyAuth( + user_id="internal-user-123", + user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + ) + + with ( + patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma, + patch( + "litellm.proxy.management_endpoints.tag_management_endpoints.get_daily_activity_aggregated", + new_callable=AsyncMock, + ) as mock_aggregated, + ): + mock_db = Mock() + mock_prisma.db = mock_db + + owned_key_record = Mock() + owned_key_record.token = "owned-key" + mock_db.litellm_verificationtoken = FakeVerificationTokenTable([owned_key_record]) + mock_aggregated.return_value = "aggregated-response" + + result = await get_tag_daily_activity_aggregated( + start_date="2025-01-01", + end_date="2025-01-31", + user_api_key_dict=mock_user_auth, + ) + + assert result == "aggregated-response" + assert mock_aggregated.await_args.kwargs["api_key"] == ["owned-key"] + assert mock_aggregated.await_args.kwargs["table_name"] == "litellm_dailytagspend" + + +@pytest.mark.parametrize( + "start_date, end_date, expected_detail_fragment", + [ + (None, "2025-01-31", "Please provide start_date and end_date"), + ("not-a-date", "2025-01-31", "valid YYYY-MM-DD dates"), + ("2025-02-01", "2025-01-31", "end_date must be on or after start_date"), + ("2020-01-01", "2026-12-31", "at most 400 days"), + ], +) +@pytest.mark.asyncio +async def test_tag_daily_activity_aggregated_rejects_unbounded_ranges(start_date, end_date, expected_detail_fragment): + """Nothing paginates this endpoint, so the range guard is the only bound on its scan.""" + from litellm.proxy.management_endpoints.tag_management_endpoints import ( + get_tag_daily_activity_aggregated, + ) + + mock_user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + mock_db = Mock() + mock_prisma.db = mock_db + mock_db.query_raw = AsyncMock() + + with pytest.raises(HTTPException) as exc_info: + await get_tag_daily_activity_aggregated( + start_date=start_date, + end_date=end_date, + user_api_key_dict=mock_user_auth, + ) + + assert exc_info.value.status_code == 400 + assert expected_detail_fragment in exc_info.value.detail + mock_db.query_raw.assert_not_awaited() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index 6bd16095351..e8100b1e456 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -52,6 +52,7 @@ beforeAll(() => { // Mock the networking module vi.mock("@/components/networking", () => ({ tagDailyActivityCall: vi.fn(), + tagDailyActivityAggregatedCall: vi.fn(), teamDailyActivityCall: vi.fn(), teamDailyActivityAggregatedCall: vi.fn(), organizationDailyActivityCall: vi.fn(), @@ -139,6 +140,7 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ describe("EntityUsage", () => { const mockTagDailyActivityCall = vi.mocked(networking.tagDailyActivityCall); + const mockTagDailyActivityAggregatedCall = vi.mocked(networking.tagDailyActivityAggregatedCall); const mockTeamDailyActivityCall = vi.mocked(networking.teamDailyActivityCall); const mockTeamDailyActivityAggregatedCall = vi.mocked(networking.teamDailyActivityAggregatedCall); const mockOrganizationDailyActivityCall = vi.mocked(networking.organizationDailyActivityCall); @@ -441,6 +443,7 @@ describe("EntityUsage", () => { beforeEach(() => { mockTagDailyActivityCall.mockClear(); + mockTagDailyActivityAggregatedCall.mockClear(); mockTeamDailyActivityCall.mockClear(); mockTeamDailyActivityAggregatedCall.mockClear(); mockOrganizationDailyActivityCall.mockClear(); @@ -448,6 +451,7 @@ describe("EntityUsage", () => { mockAgentDailyActivityCall.mockClear(); mockUserDailyActivityCall.mockClear(); mockTagDailyActivityCall.mockResolvedValue(mockSpendData); + mockTagDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); mockTeamDailyActivityCall.mockResolvedValue(mockSpendData); mockTeamDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData); @@ -516,7 +520,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument(); @@ -630,7 +634,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument(); @@ -696,7 +700,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); act(() => { @@ -748,12 +752,12 @@ describe("EntityUsage", () => { }, }; - mockTagDailyActivityCall.mockResolvedValue(emptyData); + mockTagDailyActivityAggregatedCall.mockResolvedValue(emptyData); render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); expect(await screen.findByText("Tag Spend Overview")).toBeInTheDocument(); @@ -766,7 +770,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); expect(screen.getByText("Model Activity")).toBeInTheDocument(); @@ -786,7 +790,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); expect(screen.getByText("Top Public Model Names")).toBeInTheDocument(); @@ -796,7 +800,7 @@ describe("EntityUsage", () => { const { container } = render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); act(() => { @@ -837,7 +841,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); await waitFor(() => { @@ -851,7 +855,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); await waitFor(() => { @@ -863,7 +867,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); await waitFor(() => { @@ -905,7 +909,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); expect(screen.queryByText("Agent Activity")).not.toBeInTheDocument(); @@ -925,7 +929,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); expect(screen.queryByText("Top Agents Driving Spend")).not.toBeInTheDocument(); @@ -949,7 +953,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); expect(mockAgentDailyActivityCall).not.toHaveBeenCalled(); @@ -991,12 +995,12 @@ describe("EntityUsage", () => { ], }; - mockTagDailyActivityCall.mockResolvedValue(spendDataWithoutAlias); + mockTagDailyActivityAggregatedCall.mockResolvedValue(spendDataWithoutAlias); render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); await waitFor(() => { @@ -1008,7 +1012,7 @@ describe("EntityUsage", () => { const { container } = render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); await waitFor(() => { @@ -1128,7 +1132,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTagDailyActivityCall).toHaveBeenCalled(); + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); }); expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument(); }); @@ -1149,7 +1153,7 @@ describe("EntityUsage", () => { }, ], }; - mockTagDailyActivityCall.mockResolvedValue(spendDataUnknownProvider); + mockTagDailyActivityAggregatedCall.mockResolvedValue(spendDataUnknownProvider); render(); @@ -1213,6 +1217,33 @@ describe("EntityUsage", () => { }); }); + it("uses the aggregated tag endpoint and never drains paginated pages for tags", async () => { + render(); + + await waitFor(() => { + expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + expect(mockTagDailyActivityCall).not.toHaveBeenCalled(); + + await waitFor(() => { + expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0); + }); + }); + + it("falls back to the paginated tag endpoint when the aggregated call fails", async () => { + mockTagDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); + + render(); + + await waitFor(() => { + expect(mockTagDailyActivityCall).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0); + }); + }); + it("falls back to the paginated team endpoint when the aggregated call fails", async () => { mockTeamDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable")); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 27460b21108..634ed161723 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -31,6 +31,7 @@ import { agentDailyActivityCall, customerDailyActivityCall, organizationDailyActivityCall, + tagDailyActivityAggregatedCall, tagDailyActivityCall, teamDailyActivityAggregatedCall, teamDailyActivityCall, @@ -105,6 +106,7 @@ const ENTITY_FETCH_FNS: Record Promise> = { // Single-shot endpoints returning the whole range in one response; entity types // without one fall back to page-draining the paginated endpoint. const ENTITY_AGGREGATED_FETCH_FNS: Partial Promise>> = { + tag: tagDailyActivityAggregatedCall, team: teamDailyActivityAggregatedCall, }; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 3f674ea3328..a4c7cddbdb8 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1418,6 +1418,31 @@ export const tagDailyActivityCall = async ( }); }; +export const tagDailyActivityAggregatedCall = async ( + accessToken: string, + startTime: Date, + endTime: Date, + tags: string[] | null = null, +) => { + /** + * Get aggregated daily tag activity with per-tag breakdown (no pagination) + */ + try { + return await apiClient.get(`/tag/daily/activity/aggregated`, { + accessToken, + query: { + start_date: formatDate(startTime), + end_date: formatDate(endTime), + timezone: new Date().getTimezoneOffset().toString(), + tags: tags && tags.length > 0 ? tags.join(",") : undefined, + }, + }); + } catch (error) { + console.error("Failed to fetch aggregated tag daily activity:", error); + throw error; + } +}; + export const teamDailyActivityCall = async ( accessToken: string, startTime: Date, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bdfd4aec316..118e6ac688e 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -15154,6 +15154,42 @@ export interface paths { patch?: never; trace?: never; }; + "/tag/daily/activity/aggregated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Tag Daily Activity Aggregated + * @description Daily activity for tags over the whole range in one response, with per-tag breakdown. + * + * One SQL GROUPING SETS pass reads the range from a single snapshot, so callers never + * walk pages of LiteLLM_DailyTagSpend and never re-count rows that the tag spend + * scheduler shifted across a page boundary mid-walk. Same response shape as the + * paginated endpoint with page metadata pinned to a single page. + * + * Args: + * tags (Optional[str]): Comma-separated list of tags to filter by. If not provided, returns data for all tags. + * start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD). + * end_date (Optional[str]): End date for the activity period (YYYY-MM-DD). + * model (Optional[str]): Filter by model name. + * api_key (Optional[str]): Filter by API key. + * timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset() convention. + * + * Returns: + * SpendAnalyticsPaginatedResponse: Response containing all daily activity data for the range. + */ + get: operations["get_tag_daily_activity_aggregated_tag_daily_activity_aggregated_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tag/dau": { parameters: { query?: never; @@ -61777,6 +61813,42 @@ export interface operations { }; }; }; + get_tag_daily_activity_aggregated_tag_daily_activity_aggregated_get: { + parameters: { + query?: { + tags?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_daily_active_users_tag_dau_get: { parameters: { query?: {