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.
This commit is contained in:
ryan-crabbe-berri 2026-08-11 11:16:41 -07:00
parent 657c4c2b1e
commit 97d986377c
11 changed files with 636 additions and 68 deletions

View file

@ -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",

View file

@ -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(

View file

@ -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,
)

View file

@ -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 (

View file

@ -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

View file

@ -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"

View file

@ -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(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
});
// Check that it shows team-specific label
@ -536,7 +540,7 @@ describe("EntityUsage", () => {
const { container } = render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
});
act(() => {
@ -688,7 +692,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Agent Activity")).toBeInTheDocument();
@ -708,7 +712,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Top Agents Driving Spend")).toBeInTheDocument();
@ -752,7 +756,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
});
const agentActivityTab = screen.getByText("Agent Activity");
@ -888,7 +892,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="team" userRole="Internal User" />);
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(<EntityUsage {...defaultProps} entityType="team" />);
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(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
await waitFor(() => {
expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0);
});
});
});

View file

@ -44,6 +44,7 @@ import {
customerDailyActivityCall,
organizationDailyActivityCall,
tagDailyActivityCall,
teamDailyActivityAggregatedCall,
teamDailyActivityCall,
userDailyActivityCall,
} from "@/components/networking";
@ -108,6 +109,12 @@ const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
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<Record<EntityType, (...args: any[]) => Promise<any>>> = {
team: teamDailyActivityAggregatedCall,
};
const ENTITY_CAPABILITIES: Partial<Record<EntityType, Capability>> = {
organization: "viewOrganizationUsage",
agent: "viewAgentUsage",
@ -138,6 +145,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
}, [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<EntityUsageProps> = ({
fetchFn,
args: [accessToken, startTime, endTime, entityFilterArg],
enabled,
aggregatedFetchFn,
});
const spendData = spendDataRaw as unknown as EntitySpendData;

View file

@ -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<DailyActivityResponse>;
}
interface UsePaginatedDailyActivityReturn {
@ -96,6 +103,7 @@ export function usePaginatedDailyActivity({
fetchFn,
args,
enabled,
aggregatedFetchFn,
}: UsePaginatedDailyActivityParams): UsePaginatedDailyActivityReturn {
const [data, setData] = useState<DailyActivityResponse>(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 };
}

View file

@ -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,

View file

@ -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;