From 97d986377c32fb030bdf05245ab80feb30021dcf Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 11 Aug 2026 11:16:41 -0700 Subject: [PATCH] feat(proxy): add /team/daily/activity/aggregated and use it in the Usage UI The Team Usage tab drained row-paginated pages client side, which painted newest days first and drew duplicate bars when a day's rows straddled a page boundary. Serve the whole range in one SQL GROUPING SETS pass instead: the aggregated query gains optional per-entity rollup levels (entity as the most-significant GROUPING bit) so breakdown.entities keeps per-team spend, aliases, and per-key splits. The endpoint shares the paginated route's scoping via _resolve_team_daily_activity_scope, accepts the timezone the UI already sends, and the api_key filter now takes a list so non-admin member scoping works. The dashboard tries the aggregated endpoint first and falls back to page draining on failure. --- litellm/proxy/_types.py | 5 + .../common_daily_activity.py | 87 +++++++- .../management_endpoints/team_endpoints.py | 206 ++++++++++++++---- .../management/test_team_daily_activity.py | 20 +- .../test_common_daily_activity.py | 130 +++++++++++ .../test_team_endpoints.py | 78 +++++++ .../EntityUsage/EntityUsage.test.tsx | 45 +++- .../components/EntityUsage/EntityUsage.tsx | 9 + .../hooks/usePaginatedDailyActivity.ts | 24 +- .../src/components/networking.tsx | 26 +++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 74 ++++++- 11 files changed, 636 insertions(+), 68 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index dc4f17c7b31..bad5149104a 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -265,6 +265,7 @@ class KeyManagementRoutes(str, enum.Enum): # team usage routes TEAM_DAILY_ACTIVITY = "/team/daily/activity" + TEAM_DAILY_ACTIVITY_AGGREGATED = "/team/daily/activity/aggregated" # team spend-log viewing SPEND_LOGS = "/spend/logs" @@ -589,6 +590,7 @@ class LiteLLMRoutes(enum.Enum): KeyManagementRoutes.KEY_BULK_UPDATE.value, KeyManagementRoutes.TEAM_KEY_BULK_UPDATE.value, KeyManagementRoutes.TEAM_DAILY_ACTIVITY.value, + KeyManagementRoutes.TEAM_DAILY_ACTIVITY_AGGREGATED.value, KeyManagementRoutes.SPEND_LOGS.value, KeyManagementRoutes.SPEND_LOGS_V2.value, KeyManagementRoutes.KEY_RESET_SPEND.value, @@ -623,6 +625,7 @@ class LiteLLMRoutes(enum.Enum): "/team/permissions_update", "/team/permissions_bulk_update", "/team/daily/activity", + "/team/daily/activity/aggregated", # gateway request counts (SGR); deployment-wide, admin-only "/gateway/daily/activity", # model @@ -777,6 +780,7 @@ class LiteLLMRoutes(enum.Enum): "/team/permissions_list", "/team/permissions_update", "/team/daily/activity", + "/team/daily/activity/aggregated", "/team/{team_id}/members/me", "/model/new", "/model/update", @@ -839,6 +843,7 @@ class LiteLLMRoutes(enum.Enum): "/user/available_roles", "/user/daily/activity", "/team/daily/activity", + "/team/daily/activity/aggregated", "/tag/daily/activity", "/tag/list", "/audit", diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index d1542b38996..92f6fd1bf60 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -121,6 +121,7 @@ class _AggregatedSpendData(TypedDict): class _GroupingSetsRow(SimpleNamespace): date: str + entity_id: str | None = None api_key: str | None model: str | None model_group: str | None @@ -563,9 +564,10 @@ def _build_aggregated_sql_query( start_date: str, end_date: str, model: str | None, - api_key: str | None, + api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path exclude_entity_ids: list[str] | None = None, timezone_offset_minutes: int | None = None, + entity_breakdown_field: str | None = None, ) -> tuple[str, list[str]]: """Build a parameterized SQL GROUP BY query for aggregated daily activity. @@ -574,6 +576,11 @@ def _build_aggregated_sql_query( The entity_id column is intentionally omitted from GROUP BY to collapse rows across entities — this is where the biggest row reduction comes from. + When entity_breakdown_field is set (e.g. "team_id"), two extra rollup + levels are added — (date, entity) and (date, entity, api_key) — and the + entity column joins the GROUPING() call as the most-significant bit, so + every pre-existing rollup keeps its bitmask value plus that bit set. + Returns: Tuple of (sql_query, params_list) ready for prisma_client.db.query_raw(). """ @@ -621,8 +628,16 @@ def _build_aggregated_sql_query( sql_params.append(model) p += 1 - # Optional api_key filter - if api_key: + # Optional api_key filter; an empty list must match nothing, not everything + if isinstance(api_key, list): + if api_key: + placeholders = ", ".join(f"${p + i}" for i in range(len(api_key))) + sql_conditions.append(f"api_key IN ({placeholders})") + sql_params.extend(api_key) + p += len(api_key) + else: + sql_conditions.append("FALSE") + elif api_key: sql_conditions.append(f"api_key = ${p}") sql_params.append(api_key) p += 1 @@ -649,8 +664,16 @@ def _build_aggregated_sql_query( if table_name == "litellm_dailyteamspend" else "0::float AS ptu_flat_cost" ) + entity_select: Final = f'"{entity_breakdown_field}" AS entity_id,' if entity_breakdown_field else "" + entity_grouping_arg: Final = f'"{entity_breakdown_field}", ' if entity_breakdown_field else "" + entity_grouping_sets: Final = ( + f'(date, "{entity_breakdown_field}"), (date, "{entity_breakdown_field}", api_key),' + if entity_breakdown_field + else "" + ) sql_query: Final = f""" SELECT + {entity_select} date, api_key, model, @@ -658,7 +681,7 @@ def _build_aggregated_sql_query( custom_llm_provider, mcp_namespaced_tool_name, endpoint, - GROUPING(date, api_key, model, COALESCE(NULLIF(model_group, ''), model), + GROUPING({entity_grouping_arg}date, api_key, model, COALESCE(NULLIF(model_group, ''), model), custom_llm_provider, mcp_namespaced_tool_name, endpoint) AS group_level, SUM(spend)::float AS spend, @@ -677,6 +700,7 @@ def _build_aggregated_sql_query( FROM "{pg_table}" WHERE {where_clause} GROUP BY GROUPING SETS ( + {entity_grouping_sets} (date), (date, api_key), (date, model), @@ -796,6 +820,13 @@ _GROUP_DATE_MCP_API_KEY: Final = 29 # 0b0011101 _GROUP_DATE_ENDPOINT: Final = 62 # 0b0111110 _GROUP_DATE_ENDPOINT_API_KEY: Final = 30 # 0b0011110 +# When entity breakdown is on, the entity column is prepended to the GROUPING() +# arguments as the most-significant bit. Every pre-existing rollup then carries +# this bit (entity rolled up) on top of its value above, while the two new +# entity rollup sets keep the bit clear: (date, entity) -> _GROUP_DATE and +# (date, entity, api_key) -> _GROUP_DATE_API_KEY. +_ENTITY_ROLLED_BIT: Final = 128 # 0b10000000 + def _record_to_spend_metrics(record: _GroupingSetsRow) -> SpendMetrics: """Build a SpendMetrics directly from one already-aggregated rollup row. @@ -832,6 +863,9 @@ def _aggregate_grouping_sets_records_sync( *, records: Sequence[_GroupingSetsRow], api_key_metadata: Mapping[str, _KeyMetadataDict], + entity_breakdown: bool = False, + entity_metadata_field: Mapping[str, dict[str, object]] + | None = None, # mutable-ok: matches every entity_metadata_field signature in this file ) -> _AggregatedSpendData: """Build the response from rollup rows produced by the GROUPING SETS query. @@ -871,11 +905,38 @@ def _aggregate_grouping_sets_records_sync( metrics=metrics, metadata=_key_metadata(api_key_metadata, api_key) ) + def ensure_entity(entities: dict[str, MetricWithMetadata], entity_id: str) -> MetricWithMetadata: + existing: MetricWithMetadata | None = entities.get(entity_id) + if existing is not None: + return existing + created: Final = MetricWithMetadata( + metrics=SpendMetrics(), + metadata=(entity_metadata_field.get(entity_id, {}) if entity_metadata_field else {}), + ) + entities[entity_id] = created + return created + + def assign_entity_rollup(record: _GroupingSetsRow, metrics: SpendMetrics) -> None: + entity_id: Final = record.entity_id or "Unassigned" + entity_bucket: Final = ensure_entity(ensure_date(record.date)["breakdown"].entities, entity_id) + if record.group_level == _GROUP_DATE: + entity_bucket.metrics = metrics + return + if record.api_key and record.api_key != PTU_SENTINEL_API_KEY: + entity_bucket.api_key_breakdown[record.api_key] = KeyMetricWithMetadata( + metrics=metrics, metadata=_key_metadata(api_key_metadata, record.api_key) + ) + for record in records: - level = record.group_level + level = record.group_level & ~_ENTITY_ROLLED_BIT if entity_breakdown else record.group_level metrics = _record_to_spend_metrics(record) is_ptu_sentinel = record.api_key == PTU_SENTINEL_API_KEY + if entity_breakdown and not (record.group_level & _ENTITY_ROLLED_BIT): + if level in (_GROUP_DATE, _GROUP_DATE_API_KEY): + assign_entity_rollup(record, metrics) + continue + if level == _GROUP_GRAND_TOTAL: total_metrics = metrics continue @@ -958,6 +1019,9 @@ async def _aggregate_grouping_sets_records( *, prisma_client: PrismaClient, records: Sequence[_GroupingSetsRow], + entity_breakdown: bool = False, + entity_metadata_field: Mapping[str, dict[str, object]] + | None = None, # mutable-ok: matches every entity_metadata_field signature in this file ) -> _AggregatedSpendData: """Async wrapper: fetch api_key_metadata, then dispatch on a worker thread.""" api_keys: Final[set[str]] = {r.api_key for r in records if r.api_key and r.api_key != PTU_SENTINEL_API_KEY} @@ -970,6 +1034,8 @@ async def _aggregate_grouping_sets_records( _aggregate_grouping_sets_records_sync, records=records, api_key_metadata=api_key_metadata, + entity_breakdown=entity_breakdown, + entity_metadata_field=entity_metadata_field, ) @@ -1106,9 +1172,10 @@ async def get_daily_activity_aggregated( start_date: str | None, end_date: str | None, model: str | None, - api_key: str | None, + api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path exclude_entity_ids: list[str] | None = None, timezone_offset_minutes: int | None = None, + include_entity_breakdown: bool = False, ) -> SpendAnalyticsPaginatedResponse: """Aggregated variant that returns the full result set (no pagination). @@ -1116,6 +1183,11 @@ async def get_daily_activity_aggregated( all individual rows into Python. This collapses rows across entities (users/teams/orgs), reducing ~150k rows to ~2-3k grouped rows. + include_entity_breakdown adds per-entity rollup levels to the GROUPING SETS + so `breakdown.entities` is populated (with entity_metadata_field metadata), + as entity-scoped views like Team Usage need — still one SQL pass, without + reverting to the per-leaf-row explosion that plain GROUP BY entity causes. + Matches the response model of the paginated endpoint so the UI does not need to transform. """ if prisma_client is None: @@ -1141,6 +1213,7 @@ async def get_daily_activity_aggregated( api_key=api_key, exclude_entity_ids=exclude_entity_ids, timezone_offset_minutes=timezone_offset_minutes, + entity_breakdown_field=entity_id_field if include_entity_breakdown else None, ) # Execute GROUPING SETS query — returns one row per rollup level. @@ -1155,6 +1228,8 @@ async def get_daily_activity_aggregated( aggregated: Final = await _aggregate_grouping_sets_records( prisma_client=prisma_client, records=records, + entity_breakdown=include_entity_breakdown, + entity_metadata_field=entity_metadata_field, ) return SpendAnalyticsPaginatedResponse( diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 60d3d650d00..6e14ecc7212 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -15,7 +15,7 @@ import math import traceback from collections.abc import Mapping, Sequence from datetime import datetime, timezone -from typing import Annotated, Final, Protocol, TypedDict, TypeVar, cast +from typing import Annotated, Final, NamedTuple, Protocol, TypedDict, TypeVar, cast import fastapi from fastapi import APIRouter, Depends, Header, HTTPException, Request, status @@ -87,6 +87,9 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth 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 ( + get_daily_activity_aggregated, +) from litellm.proxy.management_endpoints.common_utils import ( _check_passthrough_routes_caller_permission, _is_user_org_admin_for_team, @@ -5534,49 +5537,28 @@ async def _append_permissions_to_all_teams(prisma_client: PrismaClient, permissi return teams_updated -@router.get( - "/team/daily/activity", - response_model=SpendAnalyticsPaginatedResponse, - tags=["team management"], -) -async def get_team_daily_activity( - team_ids: str | None = None, - start_date: str | None = None, - end_date: str | None = None, - model: str | None = None, - api_key: str | None = None, - page: int = 1, - page_size: int = 10, - exclude_team_ids: str | None = None, - user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): - """ - Get daily activity for specific teams or all teams. +class _TeamDailyActivityScope(NamedTuple): + team_ids: list[str] | None # mutable-ok: downstream daily-activity signatures take str | list unions + exclude_team_ids: list[str] | None # mutable-ok: downstream daily-activity signatures take str | list unions + team_alias_metadata: dict[ + str, dict[str, object] + ] # mutable-ok: entity_metadata_field contract is Mapping[str, dict] + api_key_filter: str | list[str] | None # mutable-ok: downstream daily-activity signatures take str | list unions - Args: - team_ids (Optional[str]): Comma-separated list of team IDs to filter by. If not provided, returns data for all teams. - 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. - page (int): Page number for pagination. - page_size (int): Number of items per page. - exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. - Returns: - SpendAnalyticsPaginatedResponse: Paginated response containing daily activity data. - """ - from litellm.proxy.proxy_server import ( - prisma_client, - proxy_logging_obj, - user_api_key_cache, - ) - - if prisma_client is None: - raise HTTPException( - status_code=500, - detail={"error": CommonProxyErrors.db_not_connected_error.value}, - ) +async def _resolve_team_daily_activity_scope( + *, + team_ids: str | None, + exclude_team_ids: str | None, + api_key: str | None, + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, + user_api_key_cache: UserApiKeyCache, + proxy_logging_obj: ProxyLogging, +) -> _TeamDailyActivityScope: + """Resolve which teams the caller may see and whether results must be + narrowed to their own API keys. Shared by the paginated and aggregated + /team/daily/activity endpoints so both enforce identical permissions.""" # Convert comma-separated tags string to list if provided team_ids_list = team_ids.split(",") if team_ids else None exclude_team_ids_list: list[str] | None = None @@ -5659,17 +5641,149 @@ async def get_team_daily_activity( if final_api_key_filter is None and user_api_keys is not None: final_api_key_filter = user_api_keys + return _TeamDailyActivityScope( + team_ids=team_ids_list, + exclude_team_ids=exclude_team_ids_list, + team_alias_metadata=team_alias_metadata, + api_key_filter=final_api_key_filter, + ) + + +@router.get( + "/team/daily/activity", + response_model=SpendAnalyticsPaginatedResponse, + tags=["team management"], +) +async def get_team_daily_activity( + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + team_ids: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + model: str | None = None, + api_key: str | None = None, + page: int = 1, + page_size: int = 10, + exclude_team_ids: str | None = None, +): + """ + Get daily activity for specific teams or all teams. + + Args: + team_ids (Optional[str]): Comma-separated list of team IDs to filter by. If not provided, returns data for all teams. + 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. + page (int): Page number for pagination. + page_size (int): Number of items per page. + exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. + Returns: + SpendAnalyticsPaginatedResponse: Paginated response containing daily activity data. + """ + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + scope: Final = await _resolve_team_daily_activity_scope( + team_ids=team_ids, + exclude_team_ids=exclude_team_ids, + api_key=api_key, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + return await get_daily_activity( prisma_client=prisma_client, table_name="litellm_dailyteamspend", entity_id_field="team_id", - entity_id=team_ids_list, - entity_metadata_field=team_alias_metadata, - exclude_entity_ids=exclude_team_ids_list, + entity_id=scope.team_ids, + entity_metadata_field=scope.team_alias_metadata, + exclude_entity_ids=scope.exclude_team_ids, start_date=start_date, end_date=end_date, model=model, - api_key=final_api_key_filter, + api_key=scope.api_key_filter, page=page, page_size=page_size, ) + + +@router.get( + "/team/daily/activity/aggregated", + response_model=SpendAnalyticsPaginatedResponse, + tags=["team management"], +) +async def get_team_daily_activity_aggregated( + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + team_ids: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + model: str | None = None, + api_key: str | None = None, + exclude_team_ids: str | None = None, + timezone: int | None = None, +): + """ + Aggregated daily activity for teams without pagination, including per-team breakdown. + + One SQL GROUPING SETS pass returns every day in the range regardless of row + volume, so callers never reassemble pages. Same response shape as the + paginated endpoint with page metadata pinned to a single page. + + Args: + team_ids (Optional[str]): Comma-separated list of team IDs to filter by. If not provided, returns data for all teams. + 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. + exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. + 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, + proxy_logging_obj, + user_api_key_cache, + ) + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + scope: Final = await _resolve_team_daily_activity_scope( + team_ids=team_ids, + exclude_team_ids=exclude_team_ids, + api_key=api_key, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + return await get_daily_activity_aggregated( + prisma_client=prisma_client, + table_name="litellm_dailyteamspend", + entity_id_field="team_id", + entity_id=scope.team_ids, + entity_metadata_field=scope.team_alias_metadata, + start_date=start_date, + end_date=end_date, + model=model, + api_key=scope.api_key_filter, + exclude_entity_ids=scope.exclude_team_ids, + timezone_offset_minutes=timezone, + include_entity_breakdown=True, + ) diff --git a/tests/proxy_behavior/management/test_team_daily_activity.py b/tests/proxy_behavior/management/test_team_daily_activity.py index 7a1e70b91fc..d84cc4c94af 100644 --- a/tests/proxy_behavior/management/test_team_daily_activity.py +++ b/tests/proxy_behavior/management/test_team_daily_activity.py @@ -5,11 +5,12 @@ from .actors import Actor pytestmark = pytest.mark.asyncio(loop_scope="session") -# GET /team/daily/activity. A proxy admin (admin view) sees activity for any -# team. A non-admin is scoped to user_info.teams: a bare query defaults to its -# own teams (200), and an explicit team_ids filter naming a team it does not -# belong to is 404 (the VERIA-43 fix). Org admins have no team memberships, so -# they behave like a non-member for any specific team. +# GET /team/daily/activity and its /aggregated variant (same shared scope +# resolver, so the matrix must hold for both). A proxy admin (admin view) sees +# activity for any team. A non-admin is scoped to user_info.teams: a bare query +# defaults to its own teams (200), and an explicit team_ids filter naming a +# team it does not belong to is 404 (the VERIA-43 fix). Org admins have no +# team memberships, so they behave like a non-member for any specific team. _MEMBERS = { "alpha": { Actor.TEAM_ADMIN, @@ -40,13 +41,18 @@ _CASES = [ _DATES = "start_date=2024-01-01&end_date=2024-12-31" +@pytest.mark.parametrize( + "endpoint", + ("/team/daily/activity", "/team/daily/activity/aggregated"), + ids=("paginated", "aggregated"), +) @pytest.mark.parametrize( "actor,team,expected_status", [(a, t, s) for (_id, a, t, s) in _CASES], ids=[c[0] for c in _CASES], ) async def test_team_daily_activity_matrix( - actor: Actor, team: str, expected_status: int, proxy_client, world + actor: Actor, team: str, expected_status: int, endpoint: str, proxy_client, world ): query = _DATES if team == "alpha": @@ -55,7 +61,7 @@ async def test_team_daily_activity_matrix( query += f"&team_ids={world.team_beta_id}" resp = await proxy_client.get( - f"/team/daily/activity?{query}", + f"{endpoint}?{query}", headers={"Authorization": f"Bearer {world.keys[actor].cleartext}"}, ) assert ( 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 ab9b4bc3922..eb2e3243ac0 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 @@ -1719,3 +1719,133 @@ class TestFlagIsNotReadOnTheHotPath: reads = self._count_flag_reads([_spend_record(PTU_SENTINEL_API_KEY, spend=0.0, ptu_flat_cost=240.0)]) assert reads > 0 + + +def test_build_aggregated_sql_query_entity_breakdown_and_api_key_list(): + """entity_breakdown_field adds the two entity rollup sets with the entity + column as the most-significant GROUPING bit; a list api_key filter becomes + a parameterized IN, and an empty list must match nothing (not everything).""" + sql, params = _build_aggregated_sql_query( + table_name="litellm_dailyteamspend", + entity_id_field="team_id", + entity_id=None, + start_date="2024-01-01", + end_date="2024-01-31", + model=None, + api_key=["key-1", "key-2"], + entity_breakdown_field="team_id", + ) + assert '"team_id" AS entity_id' in sql + assert 'GROUPING("team_id", date' in sql + assert '(date, "team_id"), (date, "team_id", api_key),' in sql + assert "api_key IN ($3, $4)" in sql + assert params == ["2024-01-01", "2024-01-31", "key-1", "key-2"] + + plain_sql, _ = _build_aggregated_sql_query( + table_name="litellm_dailyteamspend", + entity_id_field="team_id", + entity_id=None, + start_date="2024-01-01", + end_date="2024-01-31", + model=None, + api_key=None, + ) + assert "entity_id" not in plain_sql + assert "GROUPING(date" in plain_sql + + empty_sql, empty_params = _build_aggregated_sql_query( + table_name="litellm_dailyteamspend", + entity_id_field="team_id", + entity_id=None, + start_date="2024-01-01", + end_date="2024-01-31", + model=None, + api_key=[], + ) + assert "FALSE" in empty_sql + assert empty_params == ["2024-01-01", "2024-01-31"] + + +@pytest.mark.asyncio +async def test_get_daily_activity_aggregated_with_entity_breakdown(): + """include_entity_breakdown must populate breakdown.entities from the + (date, entity) / (date, entity, api_key) rollups while every pre-existing + rollup still dispatches correctly with the entity bit (128) set.""" + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + + base = { + "entity_id": None, + "model": None, + "model_group": None, + "custom_llm_provider": None, + "mcp_namespaced_tool_name": None, + "endpoint": None, + "api_key": None, + "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, + "autorouter_savings_spend": 0.0, + "failed_requests": 0, + "prompt_tokens": 0, + "completion_tokens": 0, + "api_requests": 0, + "successful_requests": 0, + } + mock_rows = [ + # () grand total: 127 + entity bit + {**base, "date": None, "group_level": 255, "spend": 18.0}, + # (date): 63 + entity bit + {**base, "date": "2024-01-01", "group_level": 191, "spend": 18.0}, + # (date, entity): entity bit clear + {**base, "date": "2024-01-01", "entity_id": "team-a", "group_level": 63, "spend": 12.0}, + {**base, "date": "2024-01-01", "entity_id": "team-b", "group_level": 63, "spend": 6.0}, + # (date, entity, api_key): entity bit clear + {**base, "date": "2024-01-01", "entity_id": "team-a", "api_key": "key-1", "group_level": 31, "spend": 12.0}, + {**base, "date": "2024-01-01", "entity_id": "team-b", "api_key": "key-2", "group_level": 31, "spend": 6.0}, + # (date, model): 47 + entity bit + {**base, "date": "2024-01-01", "model": "gpt-4o", "group_level": 175, "spend": 18.0}, + # (date, api_key): 31 + entity bit + {**base, "date": "2024-01-01", "api_key": "key-1", "group_level": 159, "spend": 12.0}, + ] + + mock_prisma.db.query_raw = AsyncMock(return_value=mock_rows) + mock_prisma.db.litellm_verificationtoken = MagicMock() + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + + result = await get_daily_activity_aggregated( + prisma_client=mock_prisma, + table_name="litellm_dailyteamspend", + entity_id_field="team_id", + entity_id=None, + entity_metadata_field={"team-a": {"team_alias": "Alpha"}}, + start_date="2024-01-01", + end_date="2024-01-01", + model=None, + api_key=None, + include_entity_breakdown=True, + ) + + sql = mock_prisma.db.query_raw.call_args[0][0] + assert '"team_id" AS entity_id' in sql + assert '(date, "team_id"), (date, "team_id", api_key),' in sql + + assert result.metadata.total_spend == 18.0 + assert len(result.results) == 1 + daily = result.results[0] + assert daily.metrics.spend == 18.0 + + entities = daily.breakdown.entities + assert set(entities) == {"team-a", "team-b"} + assert entities["team-a"].metrics.spend == 12.0 + assert entities["team-a"].metadata == {"team_alias": "Alpha"} + assert entities["team-a"].api_key_breakdown["key-1"].metrics.spend == 12.0 + assert entities["team-b"].metrics.spend == 6.0 + assert entities["team-b"].metadata == {} + assert entities["team-b"].api_key_breakdown["key-2"].metrics.spend == 6.0 + + # Rollups with the entity bit set must still land in their usual buckets + assert daily.breakdown.models["gpt-4o"].metrics.spend == 18.0 + assert daily.breakdown.api_keys["key-1"].metrics.spend == 12.0 diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 6abc40eb28e..1d96dc8ef53 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -11272,3 +11272,81 @@ async def test_new_team_output_token_estimate_rejected_for_non_admin(): assert str(exc.value.code) == "403" assert "on a team" in str(exc.value.message) + + +@pytest.mark.asyncio +async def test_get_team_daily_activity_aggregated_scopes_and_flags(mock_db_client): + """The aggregated endpoint must apply the same non-admin key scoping as the + paginated one and request the per-team entity breakdown with the caller's + timezone, so the Team Usage UI gets every day in one response.""" + from litellm.proxy.management_endpoints.team_endpoints import ( + get_team_daily_activity_aggregated, + ) + + user_id = "test_user_123" + team_id = "test_team_456" + user_api_key_dict = UserAPIKeyAuth( + user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER + ) + + mock_user_info = LiteLLM_UserTable( + user_id=user_id, + teams=[team_id], + max_budget=1000.0, + spend=0.0, + user_email="test@example.com", + user_role="internal_user", + ) + + mock_team_member = Member(user_id=user_id, role="user") + mock_team = MagicMock(spec=LiteLLM_TeamTable) + mock_team.team_id = team_id + mock_team.team_alias = "Test Team" + mock_team.members_with_roles = [mock_team_member] + mock_team.model_dump.return_value = { + "team_id": team_id, + "team_alias": "Test Team", + "members_with_roles": [{"user_id": user_id, "role": "user"}], + } + + user_api_key_1 = MagicMock() + user_api_key_1.token = "user_key_1" + + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team]) + mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[user_api_key_1] + ) + + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + ) as mock_get_user_object: + mock_get_user_object.return_value = mock_user_info + + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", + new_callable=AsyncMock, + ) as mock_aggregated: + mock_aggregated.return_value = MagicMock() + + await get_team_daily_activity_aggregated( + team_ids=team_id, + start_date="2024-01-01", + end_date="2024-01-31", + model=None, + api_key=None, + exclude_team_ids=None, + timezone=480, + user_api_key_dict=user_api_key_dict, + ) + + mock_aggregated.assert_called_once() + call_kwargs = mock_aggregated.call_args[1] + assert call_kwargs["api_key"] == ["user_key_1"] + assert call_kwargs["entity_id"] == [team_id] + assert call_kwargs["entity_metadata_field"] == { + team_id: {"team_alias": "Test Team"} + } + assert call_kwargs["include_entity_breakdown"] is True + assert call_kwargs["timezone_offset_minutes"] == 480 + assert call_kwargs["table_name"] == "litellm_dailyteamspend" 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 c85a9fb71f6..883e953a24d 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 @@ -17,6 +17,7 @@ beforeAll(() => { vi.mock("@/components/networking", () => ({ tagDailyActivityCall: vi.fn(), teamDailyActivityCall: vi.fn(), + teamDailyActivityAggregatedCall: vi.fn(), organizationDailyActivityCall: vi.fn(), customerDailyActivityCall: vi.fn(), agentDailyActivityCall: vi.fn(), @@ -79,6 +80,7 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ describe("EntityUsage", () => { const mockTagDailyActivityCall = vi.mocked(networking.tagDailyActivityCall); const mockTeamDailyActivityCall = vi.mocked(networking.teamDailyActivityCall); + const mockTeamDailyActivityAggregatedCall = vi.mocked(networking.teamDailyActivityAggregatedCall); const mockOrganizationDailyActivityCall = vi.mocked(networking.organizationDailyActivityCall); const mockCustomerDailyActivityCall = vi.mocked(networking.customerDailyActivityCall); const mockAgentDailyActivityCall = vi.mocked(networking.agentDailyActivityCall); @@ -370,12 +372,14 @@ describe("EntityUsage", () => { beforeEach(() => { mockTagDailyActivityCall.mockClear(); mockTeamDailyActivityCall.mockClear(); + mockTeamDailyActivityAggregatedCall.mockClear(); mockOrganizationDailyActivityCall.mockClear(); mockCustomerDailyActivityCall.mockClear(); mockAgentDailyActivityCall.mockClear(); mockUserDailyActivityCall.mockClear(); mockTagDailyActivityCall.mockResolvedValue(mockSpendData); mockTeamDailyActivityCall.mockResolvedValue(mockSpendData); + mockTeamDailyActivityAggregatedCall.mockResolvedValue(mockSpendData); mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData); mockCustomerDailyActivityCall.mockResolvedValue(mockSpendData); mockAgentDailyActivityCall.mockResolvedValue(mockAgentSpendData); @@ -404,7 +408,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); }); // Check that it shows team-specific label @@ -536,7 +540,7 @@ describe("EntityUsage", () => { const { container } = render(); await waitFor(() => { - expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); }); act(() => { @@ -688,7 +692,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); }); expect(screen.getByText("Agent Activity")).toBeInTheDocument(); @@ -708,7 +712,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); }); expect(screen.getByText("Top Agents Driving Spend")).toBeInTheDocument(); @@ -752,7 +756,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); }); const agentActivityTab = screen.getByText("Agent Activity"); @@ -888,7 +892,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); }); expect(screen.getByText("Team Spend Overview")).toBeInTheDocument(); @@ -945,7 +949,7 @@ describe("EntityUsage", () => { cache_read_input_tokens: 0, cache_creation_input_tokens: 0, }; - mockTeamDailyActivityCall.mockResolvedValue({ + mockTeamDailyActivityAggregatedCall.mockResolvedValue({ ...mockSpendData, results: [ { @@ -967,4 +971,31 @@ describe("EntityUsage", () => { expect(screen.getByText("top-models:gpt-4o=70.25")).toBeInTheDocument(); expect(screen.getByText(/^top-models:Code Review Agent=/)).toBeInTheDocument(); }); + + it("uses the aggregated team endpoint and never drains paginated pages for teams", async () => { + render(); + + await waitFor(() => { + expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + }); + expect(mockTeamDailyActivityCall).not.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")); + + render(); + + await waitFor(() => { + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0); + }); + }); }); 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 956060fc244..3a90f61bb86 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 @@ -44,6 +44,7 @@ import { customerDailyActivityCall, organizationDailyActivityCall, tagDailyActivityCall, + teamDailyActivityAggregatedCall, teamDailyActivityCall, userDailyActivityCall, } from "@/components/networking"; @@ -108,6 +109,12 @@ const ENTITY_FETCH_FNS: Record Promise> = { user: userDailyActivityCall, }; +// 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>> = { + team: teamDailyActivityAggregatedCall, +}; + const ENTITY_CAPABILITIES: Partial> = { organization: "viewOrganizationUsage", agent: "viewAgentUsage", @@ -138,6 +145,7 @@ const EntityUsage: React.FC = ({ }, [entityType, selectedTags]); const fetchFn = ENTITY_FETCH_FNS[entityType]; + const aggregatedFetchFn = ENTITY_AGGREGATED_FETCH_FNS[entityType]; const entityCapability = ENTITY_CAPABILITIES[entityType]; const canViewEntity = entityCapability === undefined || hasCapability(userRole, entityCapability); const showAgentBreakdown = entityType === "team" && hasCapability(userRole, "viewAgentUsage"); @@ -154,6 +162,7 @@ const EntityUsage: React.FC = ({ fetchFn, args: [accessToken, startTime, endTime, entityFilterArg], enabled, + aggregatedFetchFn, }); const spendData = spendDataRaw as unknown as EntitySpendData; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts index a8bfee5be2f..453c9fae8e4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts @@ -40,6 +40,13 @@ interface UsePaginatedDailyActivityParams { args: any[]; /** Whether the hook should fetch. Set to false to disable. */ enabled: boolean; + /** + * Optional single-shot endpoint returning the whole range at once (e.g. + * teamDailyActivityAggregatedCall). Called with `args` as-is (no page). + * On success pagination is skipped entirely; on failure the hook falls + * back to the paginated flow. + */ + aggregatedFetchFn?: (...args: any[]) => Promise; } interface UsePaginatedDailyActivityReturn { @@ -96,6 +103,7 @@ export function usePaginatedDailyActivity({ fetchFn, args, enabled, + aggregatedFetchFn, }: UsePaginatedDailyActivityParams): UsePaginatedDailyActivityReturn { const [data, setData] = useState(EMPTY_DATA); const [loading, setLoading] = useState(false); @@ -159,6 +167,20 @@ export function usePaginatedDailyActivity({ setIsFetchingMore(false); setProgress({ currentPage: 1, totalPages: 1 }); + if (aggregatedFetchFn) { + try { + const aggregated = await aggregatedFetchFn(...currentArgs); + if (isStale()) return; + setData(aggregated); + setProgress({ currentPage: 1, totalPages: 1 }); + setLoading(false); + return; + } catch (error) { + if (isStale()) return; + console.error("Aggregated daily activity failed, falling back to pagination:", error); + } + } + try { // Inject page=1 as the 4th argument. const argsWithPage = [...currentArgs.slice(0, 3), 1, ...currentArgs.slice(3)]; @@ -239,7 +261,7 @@ export function usePaginatedDailyActivity({ }; // argsKey is a stable JSON string so the effect only re-fires when arg values change. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enabled, fetchFn, argsKey]); + }, [enabled, fetchFn, aggregatedFetchFn, argsKey]); return { data, loading, isFetchingMore, progress, cancelled, cancel }; } diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 321dfd6b2a6..594340a58d0 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1452,6 +1452,32 @@ export const teamDailyActivityCall = async ( }); }; +export const teamDailyActivityAggregatedCall = async ( + accessToken: string, + startTime: Date, + endTime: Date, + teamIds: string[] | null = null, +) => { + /** + * Get aggregated daily team activity with per-team breakdown (no pagination) + */ + try { + return await apiClient.get(`/team/daily/activity/aggregated`, { + accessToken, + query: { + start_date: formatDate(startTime), + end_date: formatDate(endTime), + timezone: new Date().getTimezoneOffset().toString(), + team_ids: teamIds && teamIds.length > 0 ? teamIds.join(",") : undefined, + exclude_team_ids: "litellm-dashboard", + }, + }); + } catch (error) { + console.error("Failed to fetch aggregated team daily activity:", error); + throw error; + } +}; + export const organizationDailyActivityCall = 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 8989e098f54..d0f0c0a2f0f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -13729,6 +13729,41 @@ export interface paths { patch?: never; trace?: never; }; + "/team/daily/activity/aggregated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Team Daily Activity Aggregated + * @description Aggregated daily activity for teams without pagination, including per-team breakdown. + * + * One SQL GROUPING SETS pass returns every day in the range regardless of row + * volume, so callers never reassemble pages. Same response shape as the + * paginated endpoint with page metadata pinned to a single page. + * + * Args: + * team_ids (Optional[str]): Comma-separated list of team IDs to filter by. If not provided, returns data for all teams. + * 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. + * exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. + * 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_team_daily_activity_aggregated_team_daily_activity_aggregated_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/team/delete": { parameters: { query?: never; @@ -25699,7 +25734,7 @@ export interface components { * @description Enum for key management routes * @enum {string} */ - KeyManagementRoutes: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/{key_id}/regenerate" | "/key/block" | "/key/unblock" | "/key/bulk_update" | "/team/key/bulk_update" | "/key/{key_id}/reset_spend" | "/key/access_group_assignment" | "/key/info" | "/key/health" | "/key/list" | "/key/aliases" | "/team/daily/activity" | "/spend/logs" | "/spend/logs/v2"; + KeyManagementRoutes: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/{key_id}/regenerate" | "/key/block" | "/key/unblock" | "/key/bulk_update" | "/team/key/bulk_update" | "/key/{key_id}/reset_spend" | "/key/access_group_assignment" | "/key/info" | "/key/health" | "/key/list" | "/key/aliases" | "/team/daily/activity" | "/team/daily/activity/aggregated" | "/spend/logs" | "/spend/logs/v2"; /** * KeyManagementSystem * @enum {string} @@ -52512,6 +52547,43 @@ export interface operations { }; }; }; + get_team_daily_activity_aggregated_team_daily_activity_aggregated_get: { + parameters: { + query?: { + team_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_team_ids?: 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"]; + }; + }; + }; + }; delete_team_team_delete_post: { parameters: { query?: never;