mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-07 07:58:09 +00:00
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:
parent
657c4c2b1e
commit
97d986377c
11 changed files with 636 additions and 68 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
74
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
74
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue