diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 1978c0a1b0b..be1ac686ee8 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -784,6 +784,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}",
@@ -808,6 +809,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",
]
)
@@ -895,6 +897,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 91cd80b3c81..1889780b823 100644
--- a/litellm/proxy/management_endpoints/common_daily_activity.py
+++ b/litellm/proxy/management_endpoints/common_daily_activity.py
@@ -482,6 +482,26 @@ async def get_api_key_metadata(
return result
+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 b74aa1a4e16..3b14a307d47 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(
@@ -484,7 +487,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(
@@ -573,7 +576,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(
@@ -684,7 +687,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(
@@ -721,7 +724,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(
@@ -785,3 +788,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 c6d7975b75e..a2246441e02 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -94,6 +94,7 @@ from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
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 (
@@ -6116,26 +6117,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,
@@ -6178,7 +6159,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 71c67837515..702e305c2f8 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
@@ -1330,3 +1330,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 666172947d1..b95639e6240 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
@@ -19,6 +19,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(),
@@ -93,6 +94,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);
@@ -395,6 +397,7 @@ describe("EntityUsage", () => {
beforeEach(() => {
mockTagDailyActivityCall.mockClear();
+ mockTagDailyActivityAggregatedCall.mockClear();
mockTeamDailyActivityCall.mockClear();
mockTeamDailyActivityAggregatedCall.mockClear();
mockOrganizationDailyActivityCall.mockClear();
@@ -402,6 +405,7 @@ describe("EntityUsage", () => {
mockAgentDailyActivityCall.mockClear();
mockUserDailyActivityCall.mockClear();
mockTagDailyActivityCall.mockResolvedValue(mockSpendData);
+ mockTagDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
mockTeamDailyActivityCall.mockResolvedValue(mockSpendData);
mockTeamDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData);
@@ -421,7 +425,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument();
@@ -515,7 +519,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument();
@@ -564,7 +568,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
act(() => {
@@ -616,12 +620,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();
@@ -634,7 +638,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Model Activity")).toBeInTheDocument();
@@ -654,7 +658,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Top Public Model Names")).toBeInTheDocument();
@@ -664,7 +668,7 @@ describe("EntityUsage", () => {
const { container } = render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
act(() => {
@@ -705,7 +709,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
await waitFor(() => {
@@ -719,7 +723,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
await waitFor(() => {
@@ -731,7 +735,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
await waitFor(() => {
@@ -753,7 +757,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.queryByText("Agent Activity")).not.toBeInTheDocument();
@@ -773,7 +777,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.queryByText("Top Agents Driving Spend")).not.toBeInTheDocument();
@@ -797,7 +801,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(mockAgentDailyActivityCall).not.toHaveBeenCalled();
@@ -839,12 +843,12 @@ describe("EntityUsage", () => {
],
};
- mockTagDailyActivityCall.mockResolvedValue(spendDataWithoutAlias);
+ mockTagDailyActivityAggregatedCall.mockResolvedValue(spendDataWithoutAlias);
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
await waitFor(() => {
@@ -856,7 +860,7 @@ describe("EntityUsage", () => {
const { container } = render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
await waitFor(() => {
@@ -976,7 +980,7 @@ describe("EntityUsage", () => {
render();
await waitFor(() => {
- expect(mockTagDailyActivityCall).toHaveBeenCalled();
+ expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument();
});
@@ -997,7 +1001,7 @@ describe("EntityUsage", () => {
},
],
};
- mockTagDailyActivityCall.mockResolvedValue(spendDataUnknownProvider);
+ mockTagDailyActivityAggregatedCall.mockResolvedValue(spendDataUnknownProvider);
render();
@@ -1056,6 +1060,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 9501fa7a9a1..35f1cb765a3 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
@@ -30,6 +30,7 @@ import {
agentDailyActivityCall,
customerDailyActivityCall,
organizationDailyActivityCall,
+ tagDailyActivityAggregatedCall,
tagDailyActivityCall,
teamDailyActivityAggregatedCall,
teamDailyActivityCall,
@@ -100,6 +101,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 f7368f3957d..48608f563ed 100644
--- a/ui/litellm-dashboard/src/components/networking.tsx
+++ b/ui/litellm-dashboard/src/components/networking.tsx
@@ -1451,6 +1451,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 1e1ce665970..8e0d447e04a 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -14138,6 +14138,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;
@@ -55692,6 +55728,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?: {