From be1dc08c6a94012ba3460a942864aa52c88239f1 Mon Sep 17 00:00:00 2001 From: kenneth Date: Mon, 9 Nov 2026 22:23:43 +0000 Subject: [PATCH] feat: add customer spend breakdown and filtering by current team Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_lazy_openapi_snapshot.json | 15 ++++ .../common_daily_activity.py | 48 +++++++++++- .../customer_endpoints.py | 2 + .../daily_activity_scopes.py | 4 + .../repositories/daily_activity_repository.py | 24 +++++- litellm/repositories/daily_activity_sql.py | 42 ++++++++++ .../common_daily_activity.py | 6 ++ litellm/types/repositories/daily_activity.py | 7 ++ .../spend/test_customer_spend_by_team.py | 78 +++++++++++++++++++ .../test_common_daily_activity.py | 32 ++++++++ .../test_daily_activity_routes.py | 1 + .../repositories/test_daily_activity_sql.py | 16 ++++ .../components/EntityUsage/EntityUsage.tsx | 2 + .../EntityUsage/SpendByCustomerTeam.tsx | 46 +++++++++++ .../EntityUsage/customerTeamSpend.test.ts | 41 ++++++++++ .../EntityUsage/customerTeamSpend.ts | 27 +++++++ ui/litellm-dashboard/src/i18n/locales/en.json | 5 ++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 19 +++++ 18 files changed, 410 insertions(+), 5 deletions(-) create mode 100644 tests/integration/spend/test_customer_spend_by_team.py create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByCustomerTeam.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/customerTeamSpend.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/customerTeamSpend.ts diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 746d6a1e8d7..f57d092d552 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3799,6 +3799,21 @@ }, "title": "Providers", "type": "object" + }, + "teams": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/MetricWithMetadata" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Customer spend by the key's current team, or last team for deleted keys. Keys without a known team are grouped under Unassigned", + "title": "Teams" } }, "title": "BreakdownMetrics", diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 0ac3c06cbbb..7f555f34bf8 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -47,6 +47,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendMetrics, ) from litellm.types.repositories.daily_activity import ( + CustomerTeamRollupRow, DailyActivityScope, DailyActivityTable, EntityRollupRow, @@ -967,6 +968,7 @@ async def get_daily_activity( include_current_utc_day: bool = False, resolve_entity_metadata: Callable[[Sequence[DailySpendRecord]], Awaitable[dict[str, dict[str, object]]]] | None = None, + team_ids: tuple[str, ...] | None = None, ) -> SpendAnalyticsPaginatedResponse: if prisma_client is None: raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.db_not_connected_error.value}) @@ -981,7 +983,7 @@ async def get_daily_activity( ) try: - scope: Final = daily_activity_scope( + base_scope: Final = daily_activity_scope( table_name, entity_id_field, entity_id, @@ -993,6 +995,7 @@ async def get_daily_activity( timezone_offset_minutes, include_current_utc_day, ) + scope: Final = replace(base_scope, team_ids=team_ids) repository: Final = daily_activity_repository(prisma_client) page_data: Final = await repository.daily_rows(scope, page=page, page_size=page_size) daily_spend_data: Final = page_data.rows @@ -1014,11 +1017,19 @@ async def get_daily_activity( entity_id_field=entity_id_field, entity_metadata_field=resolved_entity_metadata, ) + customer_results: Final = ( + _with_customer_teams( + aggregated["results"], + await repository.customer_team_spend(scope, row_ids=tuple(row.id for row in daily_spend_data)), + ) + if scope.table is DailyActivityTable.CUSTOMER + else aggregated["results"] + ) metadata_metrics = aggregated["totals"] if metadata_metrics_func: metadata_metrics = metadata_metrics_func(daily_spend_data) return SpendAnalyticsPaginatedResponse( - results=aggregated["results"], + results=list(customer_results), metadata=DailySpendMetadata( total_spend=metadata_metrics.spend, total_flat_cost=metadata_metrics.flat_cost, @@ -1143,6 +1154,30 @@ async def _user_entity_metadata( ) +def _with_customer_teams( + results: Sequence[DailySpendData], rows: Sequence[CustomerTeamRollupRow] +) -> tuple[DailySpendData, ...]: + return tuple( + day.model_copy( + update={ + "breakdown": day.breakdown.model_copy( + update={ + "teams": { + row.team_id: MetricWithMetadata( + metrics=_record_to_spend_metrics(row), + metadata={"team_alias": row.team_alias}, + ) + for row in rows + if row.date == day.date.isoformat() + } + } + ) + } + ) + for day in results + ) + + async def get_daily_activity_aggregated( repository: DailyActivityRepository, scope: DailyActivityScope, @@ -1190,13 +1225,18 @@ async def get_daily_activity_aggregated( api_key_metadata=entity_key_metadata, entity_metadata_field=MappingProxyType({**user_entity_metadata, **(entity_metadata_field or {})}), ) - results: Final = ( + base_results: Final = ( list(await asyncio.to_thread(_with_guardrail_breakdown, aggregated["results"], guardrail_rows)) if guardrail_rows else aggregated["results"] ) + results: Final = ( + _with_customer_teams(base_results, await repository.customer_team_spend(scope)) + if scope.table is DailyActivityTable.CUSTOMER + else base_results + ) return SpendAnalyticsPaginatedResponse( - results=results, + results=list(results), metadata=DailySpendMetadata( total_spend=aggregated["totals"].spend, total_flat_cost=aggregated["totals"].flat_cost, diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index 672bbdb164a..adcb4ad45c9 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -956,6 +956,7 @@ async def get_customer_daily_activity( page: int = 1, page_size: int = 10, exclude_end_user_ids: str | None = None, + team_ids: str | None = None, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ @@ -999,4 +1000,5 @@ async def get_customer_daily_activity( api_key=api_key, page=page, page_size=page_size, + team_ids=tuple(team_ids.split(",")) if team_ids else None, ) diff --git a/litellm/proxy/management_endpoints/daily_activity_scopes.py b/litellm/proxy/management_endpoints/daily_activity_scopes.py index 1eb6ab3210b..74151e23a28 100644 --- a/litellm/proxy/management_endpoints/daily_activity_scopes.py +++ b/litellm/proxy/management_endpoints/daily_activity_scopes.py @@ -31,6 +31,7 @@ class EntityQuery: model: str | None timezone_offset_minutes: int | None include_current_utc_day: bool + team_ids: tuple[str, ...] | None = None @dataclass(frozen=True, slots=True) @@ -91,6 +92,7 @@ def _build_scope( model=query.model, timezone_offset_minutes=query.timezone_offset_minutes, include_current_utc_day=query.include_current_utc_day, + team_ids=query.team_ids, ), entity_metadata=entity_metadata, ) @@ -321,9 +323,11 @@ def _customer_query( api_key: str | None = None, exclude_end_user_ids: str | None = None, timezone: int | None = None, + team_ids: str | None = None, ) -> EntityQuery: return EntityQuery( entity_ids=_query_ids(end_user_ids), + team_ids=_query_ids(team_ids), exclude_entity_ids=_query_excluded_ids(exclude_end_user_ids), api_key=api_key, start_date=start_date, diff --git a/litellm/repositories/daily_activity_repository.py b/litellm/repositories/daily_activity_repository.py index 321468c4976..5cdf165a822 100644 --- a/litellm/repositories/daily_activity_repository.py +++ b/litellm/repositories/daily_activity_repository.py @@ -18,6 +18,8 @@ from litellm.repositories.daily_activity_sql import ( adjust_dates_for_timezone, build_aggregated_sql, build_cache_leakage_keys_sql, + build_customer_filtered_keys_sql, + build_customer_team_rollup_sql, build_entity_rollup_sql, build_export_sql, build_guardrail_spend_sql, @@ -29,6 +31,7 @@ from litellm.repositories.daily_activity_sql import ( from litellm.repositories.prisma_protocols import TableActions from litellm.types.repositories.daily_activity import ( AggregatedRows, + CustomerTeamRollupRow, DailyActivityProxyReads, DailyActivityRow, DailyActivityScope, @@ -102,6 +105,8 @@ class DailyActivityDatabase(Protocol): _GROUPING_ADAPTER: Final = TypeAdapter(tuple[GroupingSetsRow, ...]) +_CUSTOMER_TEAM_ADAPTER: Final = TypeAdapter(tuple[CustomerTeamRollupRow, ...]) +_FILTERED_KEYS_ADAPTER: Final = TypeAdapter(tuple[dict[str, StrictStr], ...]) _ENTITY_ADAPTER: Final = TypeAdapter(tuple[EntityRollupRow, ...]) _GUARDRAIL_SPEND_ADAPTER: Final = TypeAdapter(tuple[GuardrailSpendRow, ...]) _KEY_SPEND_ADAPTER: Final = TypeAdapter(tuple[KeySpendRow, ...]) @@ -341,6 +346,16 @@ class DailyActivityRepository: if scope.exclude_entity_ids else {} ) + filtered_keys: Final = ( + tuple( + row["api_key"] + for row in _FILTERED_KEYS_ADAPTER.validate_python( + await self._query(build_customer_filtered_keys_sql(scope)) + ) + ) + if scope.table is DailyActivityTable.CUSTOMER and scope.team_ids is not None + else scope.api_keys + ) conditions: Final = { "date": {"gte": adjusted_start, "lte": adjusted_end}, **({scope.entity_id_field: {"in": list(scope.entity_ids)}} if scope.entity_ids is not None else {}), @@ -352,7 +367,7 @@ class DailyActivityRepository: if scope.model else {} ), - **({"api_key": {"in": list(scope.api_keys)}} if scope.api_keys is not None else {}), + **({"api_key": {"in": list(filtered_keys)}} if filtered_keys is not None else {}), } count, rows = await asyncio.gather( table.count(where=conditions), @@ -364,3 +379,10 @@ class DailyActivityRepository: ), ) return DailyRowsPage(total_count=count, rows=tuple(rows)) + + async def customer_team_spend( + self, scope: DailyActivityScope, *, row_ids: tuple[str, ...] | None = None + ) -> tuple[CustomerTeamRollupRow, ...]: + return _CUSTOMER_TEAM_ADAPTER.validate_python( + await self._query(build_customer_team_rollup_sql(scope, row_ids=row_ids)) + ) diff --git a/litellm/repositories/daily_activity_sql.py b/litellm/repositories/daily_activity_sql.py index 4bb52767de6..0c53af8430e 100644 --- a/litellm/repositories/daily_activity_sql.py +++ b/litellm/repositories/daily_activity_sql.py @@ -75,6 +75,7 @@ def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tu exclusion_index: Final = entity_index + int(has_entity_array) model_index: Final = exclusion_index + int(bool(scope.exclude_entity_ids)) api_keys_index: Final = model_index + int(bool(scope.model)) + team_index: Final = api_keys_index + int(bool(scope.api_keys)) conditions: Final = ( f"date >= ${start_index}", f"date <= ${start_index + 1}", @@ -105,6 +106,11 @@ def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tu if scope.api_keys else () ), + *( + (f"{_customer_team_expression(scope)} = ANY(${team_index}::text[])",) + if scope.table is DailyActivityTable.CUSTOMER and scope.team_ids is not None + else () + ), ) params: Final = ( adjusted_start, @@ -113,10 +119,46 @@ def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tu *((list(scope.exclude_entity_ids),) if scope.exclude_entity_ids else ()), *((scope.model,) if scope.model else ()), *((list(scope.api_keys),) if scope.api_keys else ()), + *((list(scope.team_ids),) if scope.table is DailyActivityTable.CUSTOMER and scope.team_ids is not None else ()), ) return " AND ".join(conditions), params +def _customer_team_expression(scope: DailyActivityScope) -> str: + table: Final = PRISMA_TO_PG_TABLE[scope.table] + return ( + "COALESCE(NULLIF((SELECT current_key.team_id FROM (" + 'SELECT token, team_id, 0 AS priority, NULL::timestamp AS deleted_at FROM "LiteLLM_VerificationToken" ' + "UNION ALL " + 'SELECT token, team_id, 1 AS priority, deleted_at FROM "LiteLLM_DeletedVerificationToken"' + f') current_key WHERE current_key.token = "{table}".api_key ' + "ORDER BY priority, deleted_at DESC LIMIT 1), ''), 'Unassigned')" + ) + + +def build_customer_team_rollup_sql(scope: DailyActivityScope, *, row_ids: tuple[str, ...] | None = None) -> SqlQuery: + where_clause, params = build_where_clause(scope) + team: Final = _customer_team_expression(scope) + rows_filter: Final = f" AND id = ANY(${len(params) + 1}::text[])" if row_ids is not None else "" + return SqlQuery( + sql=f'''SELECT date, NULL::text AS api_key, customer_team_id AS team_id, + (SELECT team_alias FROM "LiteLLM_TeamTable" WHERE team_id = scoped.customer_team_id) AS team_alias, + {_rollup_metric_select(scope.table)} + FROM (SELECT *, {team} AS customer_team_id FROM "{PRISMA_TO_PG_TABLE[scope.table]}" + WHERE {where_clause}{rows_filter}) scoped + GROUP BY date, customer_team_id''', + params=(*params, *((list(row_ids),) if row_ids is not None else ())), + ) + + +def build_customer_filtered_keys_sql(scope: DailyActivityScope) -> SqlQuery: + where_clause, params = build_where_clause(scope) + return SqlQuery( + sql=f'SELECT DISTINCT api_key FROM "{PRISMA_TO_PG_TABLE[scope.table]}" WHERE {where_clause}', + params=params, + ) + + def _ptu_flat_cost_select(table: DailyActivityTable, *, aggregate: bool = True) -> str: if table is DailyActivityTable.TEAM: return "SUM(ptu_flat_cost)::float AS ptu_flat_cost" if aggregate else "SUM(scoped.ptu_flat_cost)::float" diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 37f59711050..19791b7f620 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -108,6 +108,12 @@ class BreakdownMetrics(LiteLLMBaseModel): endpoints: dict[str, MetricWithMetadata] = Field(default_factory=dict) # endpoint -> {metrics, metadata} api_keys: dict[str, KeyMetricWithMetadata] = Field(default_factory=dict) # api_key -> {metrics, metadata} entities: dict[str, MetricWithMetadata] = Field(default_factory=dict) # entity -> {metrics, metadata} + teams: Mapping[str, MetricWithMetadata] | None = Field( + default=None, + exclude_if=lambda value: value is None, + description="Customer spend by the key's current team, or last team for deleted keys. " + "Keys without a known team are grouped under Unassigned", + ) guardrails: Mapping[str, GuardrailMetric] = Field( default_factory=dict, description="Spend by guardrail ID, already included in key and model spend. " diff --git a/litellm/types/repositories/daily_activity.py b/litellm/types/repositories/daily_activity.py index b013ce43113..27e169cec5a 100644 --- a/litellm/types/repositories/daily_activity.py +++ b/litellm/types/repositories/daily_activity.py @@ -39,6 +39,7 @@ class DailyActivityScope: model: str | None timezone_offset_minutes: int | None include_current_utc_day: bool = False + team_ids: tuple[str, ...] | None = None def __post_init__(self) -> None: if self.entity_id_field not in _ENTITY_FIELDS[self.table]: @@ -168,6 +169,12 @@ class EntityRollupRow(RollupMetricsRow): distinct_api_keys: int | None +@dataclass(frozen=True, slots=True) +class CustomerTeamRollupRow(RollupMetricsRow): + team_id: str + team_alias: str | None + + @dataclass(frozen=True, slots=True) class GuardrailSpendRow: date: str diff --git a/tests/integration/spend/test_customer_spend_by_team.py b/tests/integration/spend/test_customer_spend_by_team.py new file mode 100644 index 00000000000..77374b5e8b0 --- /dev/null +++ b/tests/integration/spend/test_customer_spend_by_team.py @@ -0,0 +1,78 @@ +import uuid +from datetime import datetime, timedelta, timezone +from typing import Final + +import pytest +from integration._support.client import Gateway, eventually, string_value +from integration._support.database import read_rows + +from litellm.types.proxy.management_endpoints.common_daily_activity import SpendAnalyticsPaginatedResponse + + +@pytest.mark.parametrize( + "route", + ( + "/customer/daily/activity", + "/end_user/daily/activity", + "/customer/daily/activity/aggregated", + "/end_user/daily/activity/aggregated", + ), +) +def test_customer_spend_by_current_team_and_team_filter(gateway: Gateway, route: str) -> None: + with gateway.scenario() as scenario: + model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) + team_a: Final = scenario.team(team_alias="customer-team-a") + team_b: Final = scenario.team(team_alias="customer-team-b") + key_a: Final = scenario.key(models=[model], team_id=team_a) + key_b: Final = string_value(gateway.post("/key/generate", {"models": [model], "team_id": team_b})["key"]) + scenario.cleanups.callback(gateway.request, "POST", "/key/delete", {"keys": [key_b]}) + key_unassigned: Final = scenario.key(models=[model]) + customer: Final = f"customer-{uuid.uuid4().hex}" + scenario.cleanups.callback(gateway.request, "POST", "/customer/delete", {"user_ids": [customer]}) + for index, key in enumerate((key_a, key_b, key_unassigned)): + response: Final = gateway.request( + "POST", + "/v1/chat/completions", + {"model": model, "messages": [{"role": "user", "content": f"hello {index}"}], "user": customer}, + key=key, + ) + assert response.status_code == 200, response.text + eventually( + lambda: read_rows( + 'SELECT SUM(api_requests)::int AS requests FROM "LiteLLM_DailyEndUserSpend" WHERE end_user_id=%s', + (customer,), + ), + lambda rows: rows[0]["requests"] == 3, + seconds=70, + ) + today: Final = datetime.now(timezone.utc).date() + start: Final = today - timedelta(days=1) + end: Final = today + timedelta(days=1) + query: Final = f"{route}?end_user_ids={customer}&start_date={start}&end_date={end}&page_size=100" + result: Final = gateway.request("GET", query) + assert result.status_code == 200, result.text + body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(result.content) + assert body.metadata.total_spend == pytest.approx(3 * (20 * 0.001 + 20 * 0.002)) + assert len(body.results) == 1 + teams: Final = body.results[0].breakdown.teams + assert teams is not None + assert set(teams) == {team_a, team_b, "Unassigned"} + assert teams[team_a].metadata["team_alias"] == "customer-team-a" + assert teams[team_b].metadata["team_alias"] == "customer-team-b" + assert sum(bucket.metrics.spend for bucket in teams.values()) == pytest.approx(body.metadata.total_spend) + assert all(bucket.metrics.api_requests == 1 for bucket in teams.values()) + assert all(bucket.metrics.total_tokens == 40 for bucket in teams.values()) + filtered: Final = gateway.request("GET", f"{query}&team_ids={team_a}") + assert filtered.status_code == 200, filtered.text + filtered_body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(filtered.content) + assert filtered_body.metadata.total_spend == pytest.approx(20 * 0.001 + 20 * 0.002) + assert set(filtered_body.results[0].breakdown.teams or {}) == {team_a} + empty: Final = gateway.request("GET", f"{query}&team_ids=missing-team") + assert empty.status_code == 200, empty.text + assert SpendAnalyticsPaginatedResponse.model_validate_json(empty.content).metadata.total_spend == 0 + scenario.delete_key(key_b) + deleted: Final = gateway.request("GET", f"{query}&team_ids={team_b}") + assert deleted.status_code == 200, deleted.text + deleted_body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(deleted.content) + assert deleted_body.metadata.total_spend == pytest.approx(20 * 0.001 + 20 * 0.002) + assert set(deleted_body.results[0].breakdown.teams or {}) == {team_b} diff --git a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py index 1cfe295b15d..cbf265a359d 100644 --- a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py @@ -41,6 +41,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( ) from litellm.types.repositories.daily_activity import ( AggregatedRows, + CustomerTeamRollupRow, DailyActivityScope, EntityRollupRow, GroupingSetsRow, @@ -3053,3 +3054,34 @@ def test_metric_merges_preserve_every_additive_field() -> None: metrics: Final = SpendMetrics.model_validate(dict.fromkeys(SpendMetrics.model_fields, 2)) merged: Final = common_daily_activity_module.sum_spend_metrics((SpendMetrics(), metrics, metrics)) assert merged.model_dump() == dict.fromkeys(SpendMetrics.model_fields, 4) + + +@pytest.mark.parametrize("team_ids", (("team-a", "Unassigned"), ())) +def test_customer_teams_preserve_metrics_and_unassigned_bucket(team_ids: tuple[str, ...]) -> None: + from litellm.types.proxy.management_endpoints.common_daily_activity import DailySpendData + + rows: Final = tuple( + CustomerTeamRollupRow( + date="2026-01-01", + api_key=None, + team_id=team_id, + team_alias="Team A" if team_id == "team-a" else None, + **{ + field.name: 2 + for field in fields(CustomerTeamRollupRow) + if field.name not in ("date", "api_key", "team_id", "team_alias") + }, + ) + for team_id in team_ids + ) + day: Final = DailySpendData(date=date(2026, 1, 1), metrics=SpendMetrics()) + results: Final = common_daily_activity_module._with_customer_teams((day,), rows) + teams: Final = results[0].breakdown.teams + assert teams is not None + assert tuple(teams) == team_ids + for team_id in team_ids: + assert teams[team_id].metrics.spend == 2 + assert teams[team_id].metrics.api_requests == 2 + assert teams[team_id].metrics.total_tokens == 4 + assert teams[team_id].metadata == {"team_alias": "Team A" if team_id == "team-a" else None} + assert "teams" not in day.breakdown.model_dump() diff --git a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py index 4ad17272fad..9cd2bab07f0 100644 --- a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py +++ b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py @@ -256,6 +256,7 @@ class _FakeRepository: self._rows: Final = rows self.aggregated = AsyncMock(side_effect=self._aggregated) self.guardrail_spend = AsyncMock(return_value=()) + self.customer_team_spend = AsyncMock(return_value=()) self.key_page = AsyncMock(side_effect=self._key_page) self.key_page_call: tuple[DailyActivityScope, int, int] | None = None self.search_keys = AsyncMock(side_effect=self._search_keys) diff --git a/tests/unit/repositories/test_daily_activity_sql.py b/tests/unit/repositories/test_daily_activity_sql.py index f8375b93dad..78057f75a81 100644 --- a/tests/unit/repositories/test_daily_activity_sql.py +++ b/tests/unit/repositories/test_daily_activity_sql.py @@ -1,3 +1,4 @@ +from dataclasses import replace from datetime import datetime, timezone from typing import Final @@ -80,6 +81,21 @@ def test_where_clause_binds_each_filter_as_a_single_array_parameter() -> None: ) +@pytest.mark.parametrize("teams", (("team-a", "team-b"), (), ("Unassigned",))) +def test_customer_team_filter_is_separate_from_customer_and_key_filters(teams: tuple[str, ...]) -> None: + scope: Final = replace( + _scope(table=DailyActivityTable.CUSTOMER, api_keys=("key-a",), model="model-a"), team_ids=teams + ) + sql, params = build_where_clause(scope) + assert '"end_user_id" = ANY($3::text[])' in sql + assert "api_key = ANY($5::text[])" in sql + assert "= ANY($6::text[])" in sql + assert '"LiteLLM_VerificationToken"' in sql + assert '"LiteLLM_DeletedVerificationToken"' in sql + assert "ORDER BY priority, deleted_at DESC LIMIT 1" in sql + assert params == ("2026-01-01", "2026-01-31", ["user-1"], "model-a", ["key-a"], list(teams)) + + def test_where_clause_exclusion_keeps_null_entity_rows() -> None: scope = _scope(table=DailyActivityTable.TEAM, entity_ids=None, exclude_entity_ids=("litellm-dashboard",)) 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 f12f59f9c28..746b10173a9 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 @@ -58,6 +58,7 @@ import { Panel, Segmented, Sparkline, Stat } from "../overview/Primitives"; import TopModelView from "./TopModelView"; import TeamUserSpendCard from "./TeamUserSpendCard"; import { ProviderSpendBreakdown } from "./SpendByProvider"; +import SpendByCustomerTeam from "./SpendByCustomerTeam"; const BRAND = "#2b3fd6"; const FLAT_COST_SERIES: ParseKeys = "usage.entityUsage.chart.flatCostSeries"; @@ -649,6 +650,7 @@ const EntityUsage: React.FC = ({ + {entityType === "customer" && } ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByCustomerTeam.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByCustomerTeam.tsx new file mode 100644 index 00000000000..21d745910e4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/SpendByCustomerTeam.tsx @@ -0,0 +1,46 @@ +import { DataTable } from "@/components/shared/DataTable"; +import { MoneyCell } from "@/components/shared/table_cells"; +import type { ColumnDef } from "@tanstack/react-table"; +import { useTranslation } from "react-i18next"; +import i18n from "@/i18n/i18n"; +import type { DailyActivityAggregatedResponse } from "@/components/UsagePage/dailyActivityApi"; +import { customerTeamSpend, type CustomerTeamSpend } from "./customerTeamSpend"; + +const columns: ColumnDef[] = [ + { + header: () => i18n.t("usage.teamUserSpend.columns.team"), + accessorKey: "teamAlias", + cell: ({ row }) => ( +
+
{row.original.teamAlias}
+ {row.original.teamAlias !== row.original.teamId && ( +
{row.original.teamId}
+ )} +
+ ), + }, + { + header: () => i18n.t("usage.teamUserSpend.columns.spend"), + accessorKey: "spend", + cell: ({ row }) => , + }, + { header: () => i18n.t("usage.teamUserSpend.columns.requests"), accessorKey: "requests" }, + { header: () => i18n.t("usage.teamUserSpend.columns.tokens"), accessorKey: "tokens" }, +]; + +export default function SpendByCustomerTeam({ response }: { response: DailyActivityAggregatedResponse }) { + const { t } = useTranslation(); + return ( +
+

{t("usage.customerTeamSpend.title")}

+

{t("usage.customerTeamSpend.attributionNote")}

+ row.teamId} + noDataMessage={t("usage.customerTeamSpend.noData")} + size="compact" + /> +
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/customerTeamSpend.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/customerTeamSpend.test.ts new file mode 100644 index 00000000000..2fd48dc7b24 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/customerTeamSpend.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { + EMPTY_DAILY_ACTIVITY_METADATA, + type DailyActivityAggregatedResponse, +} from "@/components/UsagePage/dailyActivityApi"; +import { customerTeamSpend } from "./customerTeamSpend"; + +describe("customerTeamSpend", () => { + it("sums spend, requests and tokens across days while keeping unassigned spend", () => { + const response: DailyActivityAggregatedResponse = { + metadata: EMPTY_DAILY_ACTIVITY_METADATA, + results: ["2026-01-01", "2026-01-02"].map((date) => ({ + date, + metrics: {}, + breakdown: { + teams: { + "team-a": { + metrics: { spend: 2, api_requests: 3, total_tokens: 40 }, + metadata: { team_alias: "Team A" }, + }, + Unassigned: { metrics: { spend: 1, api_requests: 1, total_tokens: 10 }, metadata: {} }, + }, + }, + })), + }; + expect(customerTeamSpend(response)).toEqual([ + { teamId: "team-a", teamAlias: "Team A", spend: 4, requests: 6, tokens: 80 }, + { teamId: "Unassigned", teamAlias: "Unassigned", spend: 2, requests: 2, tokens: 20 }, + ]); + }); + + it("returns no rows for empty or older responses without a team breakdown", () => { + expect(customerTeamSpend({ metadata: EMPTY_DAILY_ACTIVITY_METADATA, results: [] })).toEqual([]); + expect( + customerTeamSpend({ + metadata: EMPTY_DAILY_ACTIVITY_METADATA, + results: [{ date: "2026-01-01", metrics: {}, breakdown: {} }], + }), + ).toEqual([]); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/customerTeamSpend.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/customerTeamSpend.ts new file mode 100644 index 00000000000..50c9fe28e28 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/customerTeamSpend.ts @@ -0,0 +1,27 @@ +import type { DailyActivityAggregatedResponse } from "@/components/UsagePage/dailyActivityApi"; + +export interface CustomerTeamSpend { + teamId: string; + teamAlias: string; + spend: number; + requests: number; + tokens: number; +} + +export function customerTeamSpend(response: DailyActivityAggregatedResponse): CustomerTeamSpend[] { + const entries = (response.results ?? []).flatMap((day) => Object.entries(day.breakdown?.teams ?? {})); + const ids = [...new Set(entries.map(([id]) => id))]; + return ids + .map((teamId) => { + const buckets = entries.filter(([id]) => id === teamId).map(([, entry]) => entry); + const alias = buckets.find((entry) => typeof entry.metadata?.team_alias === "string")?.metadata?.team_alias; + return { + teamId, + teamAlias: typeof alias === "string" ? alias : teamId, + spend: buckets.reduce((total, entry) => total + (entry.metrics.spend ?? 0), 0), + requests: buckets.reduce((total, entry) => total + (entry.metrics.api_requests ?? 0), 0), + tokens: buckets.reduce((total, entry) => total + (entry.metrics.total_tokens ?? 0), 0), + }; + }) + .sort((a, b) => b.spend - a.spend || a.teamId.localeCompare(b.teamId)); +} diff --git a/ui/litellm-dashboard/src/i18n/locales/en.json b/ui/litellm-dashboard/src/i18n/locales/en.json index 3462856a5d0..a0660d82df1 100644 --- a/ui/litellm-dashboard/src/i18n/locales/en.json +++ b/ui/litellm-dashboard/src/i18n/locales/en.json @@ -15625,6 +15625,11 @@ "showUnknown": "Show Unknown", "showUnknownHint": "Requests that failed to route to a provider" }, + "customerTeamSpend": { + "title": "Spend by team", + "attributionNote": "Uses each key's current team, or its last team when deleted. This is not historical team ownership.", + "noData": "No customer spend for this period" + }, "teamUserSpend": { "columns": { "team": "Team", diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 00c1b855033..b0e72ff261e 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -28757,6 +28757,13 @@ export interface components { providers?: { [key: string]: components["schemas"]["MetricWithMetadata"]; }; + /** + * Teams + * @description Customer spend by the key's current team, or last team for deleted keys. Keys without a known team are grouped under Unassigned + */ + teams?: { + [key: string]: components["schemas"]["MetricWithMetadata"]; + } | null; }; /** BudgetConfig */ BudgetConfig: { @@ -59743,6 +59750,7 @@ export interface operations { page?: number; page_size?: number; exclude_end_user_ids?: string | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -59781,6 +59789,7 @@ export interface operations { api_key?: string | null; exclude_end_user_ids?: string | null; timezone?: number | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -59820,6 +59829,7 @@ export interface operations { api_key?: string | null; exclude_end_user_ids?: string | null; timezone?: number | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -59860,6 +59870,7 @@ export interface operations { api_key?: string | null; exclude_end_user_ids?: string | null; timezone?: number | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -59899,6 +59910,7 @@ export interface operations { api_key?: string | null; exclude_end_user_ids?: string | null; timezone?: number | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -59938,6 +59950,7 @@ export interface operations { api_key?: string | null; exclude_end_user_ids?: string | null; timezone?: number | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -60794,6 +60807,7 @@ export interface operations { page?: number; page_size?: number; exclude_end_user_ids?: string | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -60832,6 +60846,7 @@ export interface operations { api_key?: string | null; exclude_end_user_ids?: string | null; timezone?: number | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -60871,6 +60886,7 @@ export interface operations { api_key?: string | null; exclude_end_user_ids?: string | null; timezone?: number | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -60911,6 +60927,7 @@ export interface operations { api_key?: string | null; exclude_end_user_ids?: string | null; timezone?: number | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -60950,6 +60967,7 @@ export interface operations { api_key?: string | null; exclude_end_user_ids?: string | null; timezone?: number | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -60989,6 +61007,7 @@ export interface operations { api_key?: string | null; exclude_end_user_ids?: string | null; timezone?: number | null; + team_ids?: string | null; }; header?: never; path?: never;