From 7ccc0e0ffff4eb64e693a40831c09336b8c455f1 Mon Sep 17 00:00:00 2001 From: kenneth-berri Date: Mon, 9 Nov 2026 19:35:31 -0800 Subject: [PATCH] feat(usage): split customer spend by the key's current team (#50764) * feat: add customer spend breakdown and filtering by current team Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: recover legacy customer team spend and tolerate lookup outages Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * perf: resolve customer team keys once per daily activity query Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: paginate customer team activity in SQL Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- 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 | 55 +++++- .../customer_endpoints.py | 2 + .../daily_activity_scopes.py | 4 + .../spend_tracking/key_metadata_recovery.py | 19 +- .../repositories/daily_activity_repository.py | 57 +++++- litellm/repositories/daily_activity_sql.py | 89 ++++++++- litellm/repositories/key_metadata_sql.py | 15 ++ .../common_daily_activity.py | 6 + litellm/types/repositories/daily_activity.py | 7 + .../spend/test_customer_spend_by_team.py | 185 ++++++++++++++++++ .../test_common_daily_activity.py | 85 +++++++- .../test_daily_activity_routes.py | 1 + .../test_daily_activity_repository.py | 32 ++- .../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 ++ 20 files changed, 688 insertions(+), 24 deletions(-) create mode 100644 litellm/repositories/key_metadata_sql.py 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..a0db1b00ec4 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,23 @@ 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 unless_database_fails( + repository.customer_team_spend(scope, row_ids=tuple(row.id for row in daily_spend_data)), + (), + "its customer team breakdown", + ), + ) + 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 +1158,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 +1229,21 @@ 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 unless_database_fails(repository.customer_team_spend(scope), (), "its customer team breakdown"), + ) + 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/proxy/spend_tracking/key_metadata_recovery.py b/litellm/proxy/spend_tracking/key_metadata_recovery.py index df156ccedd6..b3b5e41328d 100644 --- a/litellm/proxy/spend_tracking/key_metadata_recovery.py +++ b/litellm/proxy/spend_tracking/key_metadata_recovery.py @@ -23,25 +23,12 @@ from litellm.litellm_core_utils.litellm_logging import is_valid_sha256_hash from litellm.proxy.db.db_span import db_span, db_spanned from litellm.proxy.utils import PrismaClient, query_raw_rows from litellm.repositories.chunked_in import find_many_in +from litellm.repositories.key_metadata_sql import ACTIVE_TOKEN_DIGEST_SQL, DELETED_TOKEN_DIGEST_SQL from litellm.repositories.user_repository import UserRepository from litellm.types.llms.base import LiteLLMBaseModel _T = TypeVar("_T") -_ACTIVE_TOKEN_DIGEST_SQL: Final = """ -SELECT encode(sha256(convert_to(token, 'UTF8')), 'hex') AS digest, key_alias, team_id, user_id -FROM "LiteLLM_VerificationToken" -WHERE encode(sha256(convert_to(token, 'UTF8')), 'hex') = ANY($1::text[]) -""" - -_DELETED_TOKEN_DIGEST_SQL: Final = """ -SELECT DISTINCT ON (token) - encode(sha256(convert_to(token, 'UTF8')), 'hex') AS digest, key_alias, team_id, user_id -FROM "LiteLLM_DeletedVerificationToken" -WHERE encode(sha256(convert_to(token, 'UTF8')), 'hex') = ANY($1::text[]) -ORDER BY token, deleted_at DESC -""" - def _named_spend_log_edge_row_sql( direction: Literal["ASC", "DESC"], since: Literal["$2::timestamp", "oldest_probe.stopped_at"] @@ -370,7 +357,7 @@ async def recover_double_hashed_key_metadata( from_active: Final = await _reverse_hash_key_metadata( prisma_client, - _ACTIVE_TOKEN_DIGEST_SQL, + ACTIVE_TOKEN_DIGEST_SQL, sha_missing, table="LiteLLM_VerificationToken", warning="Failed reverse-hash recovery against active keys for %d missing keys: %s", @@ -380,7 +367,7 @@ async def recover_double_hashed_key_metadata( return from_active from_deleted: Final = await _reverse_hash_key_metadata( prisma_client, - _DELETED_TOKEN_DIGEST_SQL, + DELETED_TOKEN_DIGEST_SQL, still_missing, table="LiteLLM_DeletedVerificationToken", warning="Failed reverse-hash recovery against deleted keys for %d missing keys: %s", diff --git a/litellm/repositories/daily_activity_repository.py b/litellm/repositories/daily_activity_repository.py index 321468c4976..809f78e378c 100644 --- a/litellm/repositories/daily_activity_repository.py +++ b/litellm/repositories/daily_activity_repository.py @@ -1,11 +1,12 @@ import asyncio from collections.abc import AsyncIterator, Mapping, Sequence +from dataclasses import dataclass from datetime import datetime from itertools import groupby from types import MappingProxyType from typing import Final, Protocol -from pydantic import StrictStr, TypeAdapter, ValidationError +from pydantic import BaseModel, ConfigDict, StrictStr, TypeAdapter, ValidationError from typing_extensions import assert_never from litellm import constants @@ -18,6 +19,8 @@ from litellm.repositories.daily_activity_sql import ( adjust_dates_for_timezone, build_aggregated_sql, build_cache_leakage_keys_sql, + build_customer_daily_rows_sql, + build_customer_team_rollup_sql, build_entity_rollup_sql, build_export_sql, build_guardrail_spend_sql, @@ -29,6 +32,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 +106,7 @@ class DailyActivityDatabase(Protocol): _GROUPING_ADAPTER: Final = TypeAdapter(tuple[GroupingSetsRow, ...]) +_CUSTOMER_TEAM_ADAPTER: Final = TypeAdapter(tuple[CustomerTeamRollupRow, ...]) _ENTITY_ADAPTER: Final = TypeAdapter(tuple[EntityRollupRow, ...]) _GUARDRAIL_SPEND_ADAPTER: Final = TypeAdapter(tuple[GuardrailSpendRow, ...]) _KEY_SPEND_ADAPTER: Final = TypeAdapter(tuple[KeySpendRow, ...]) @@ -111,6 +116,44 @@ _EXPORT_ADAPTER: Final = TypeAdapter(tuple[ExportRow, ...]) _METADATA_TAGS_ADAPTER: Final = TypeAdapter(list[StrictStr]) +class _CustomerDailyRow(BaseModel): + model_config = ConfigDict(frozen=True) + + id: str + date: str + api_key: str + end_user_id: str | None + prompt_tokens: int + completion_tokens: int + cache_read_input_tokens: int + cache_creation_input_tokens: int + compression_saved_tokens: int + compression_savings_spend: float + prompt_caching_savings_spend: float + gateway_injected_caching_savings_spend: float + autorouter_savings_spend: float + spend: float + api_requests: int + successful_requests: int + failed_requests: int + total_response_time_ms: int + timed_requests: int + model: str | None = None + model_group: str | None = None + custom_llm_provider: str | None = None + mcp_namespaced_tool_name: str | None = None + endpoint: str | None = None + + +@dataclass(frozen=True, slots=True) +class _CustomerDailyRowsResult: + total_count: int + rows: tuple[_CustomerDailyRow, ...] + + +_CUSTOMER_DAILY_ROWS_ADAPTER: Final = TypeAdapter(tuple[_CustomerDailyRowsResult]) + + def _metadata_tags(value: object) -> tuple[str, ...]: stable_value: Final = value if not isinstance(value, list): @@ -324,6 +367,11 @@ class DailyActivityRepository: ) async def daily_rows(self, scope: DailyActivityScope, *, page: int, page_size: int) -> DailyRowsPage: + if scope.table is DailyActivityTable.CUSTOMER and scope.team_ids is not None: + result: Final = _CUSTOMER_DAILY_ROWS_ADAPTER.validate_python( + await self._query(build_customer_daily_rows_sql(scope, page=page, page_size=page_size)) + )[0] + return DailyRowsPage(total_count=result.total_count, rows=result.rows) table: Final = _daily_rows_table(self._prisma_client, scope.table) adjusted_start, adjusted_end = adjust_dates_for_timezone( scope.start_date, @@ -364,3 +412,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..5d1f85658ea 100644 --- a/litellm/repositories/daily_activity_sql.py +++ b/litellm/repositories/daily_activity_sql.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timedelta, timezone from itertools import count, islice from types import MappingProxyType @@ -10,6 +10,7 @@ from typing_extensions import assert_never from litellm import constants from litellm.constants import PTU_SENTINEL_API_KEY, UNKNOWN_MODEL_SPEND_LOG_MODEL from litellm.repositories.daily_activity_buckets import UNATTRIBUTED_API_KEY, UNKNOWN_MODEL_GROUP +from litellm.repositories.key_metadata_sql import ACTIVE_TOKEN_DIGEST_SQL, DELETED_TOKEN_DIGEST_SQL from litellm.types.repositories.daily_activity import DailyActivityScope, DailyActivityTable, ExportType _API_KEY_ROLLED_UP_BIT: Final = 32 @@ -75,6 +76,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 +107,14 @@ def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tu if scope.api_keys else () ), + *( + ( + f"api_key IN (SELECT api_key FROM ({_customer_key_teams_sql(scope)}) customer_keys " + f"WHERE team_id = ANY(${team_index}::text[]))", + ) + if scope.table is DailyActivityTable.CUSTOMER and scope.team_ids is not None + else () + ), ) params: Final = ( adjusted_start, @@ -113,10 +123,87 @@ 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_key_teams_sql(scope: DailyActivityScope) -> str: + table: Final = PRISMA_TO_PG_TABLE[scope.table] + where_clause, _ = build_where_clause(replace(scope, team_ids=None)) + direct: Final = ( + "SELECT current_key.token, 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"' + ") current_key WHERE current_key.token = scoped_keys.api_key " + "ORDER BY priority, deleted_at DESC LIMIT 1" + ) + active_digest: Final = ACTIVE_TOKEN_DIGEST_SQL.replace("$1", "ARRAY(SELECT api_key FROM missing)") + deleted_digest: Final = DELETED_TOKEN_DIGEST_SQL.replace("$1", "ARRAY(SELECT api_key FROM still_missing)") + return f'''WITH scoped_keys AS MATERIALIZED ( + SELECT DISTINCT api_key FROM "{table}" WHERE {where_clause} + ), direct AS MATERIALIZED ( + SELECT scoped_keys.api_key, current_key.token, current_key.team_id + FROM scoped_keys LEFT JOIN LATERAL ({direct}) current_key ON TRUE + ), missing AS MATERIALIZED ( + SELECT api_key FROM direct WHERE token IS NULL AND api_key ~ '^[0-9a-f]{{64}}$' + ), active_recovered AS MATERIALIZED ( + SELECT * FROM ({active_digest}) recovered WHERE EXISTS (SELECT 1 FROM missing) + ), still_missing AS MATERIALIZED ( + SELECT api_key FROM missing WHERE NOT EXISTS ( + SELECT 1 FROM active_recovered WHERE digest = missing.api_key + ) + ), deleted_recovered AS MATERIALIZED ( + SELECT * FROM ({deleted_digest}) recovered WHERE EXISTS (SELECT 1 FROM still_missing) + ) + SELECT direct.api_key, COALESCE(NULLIF( + CASE WHEN direct.token IS NOT NULL THEN direct.team_id + ELSE COALESCE(active_recovered.team_id, deleted_recovered.team_id) END, ''), 'Unassigned') AS team_id + FROM direct LEFT JOIN active_recovered ON direct.api_key = active_recovered.digest + LEFT JOIN deleted_recovered ON direct.api_key = deleted_recovered.digest''' + + +def build_customer_team_rollup_sql(scope: DailyActivityScope, *, row_ids: tuple[str, ...] | None = None) -> SqlQuery: + where_clause, params = build_where_clause(replace(scope, team_ids=None)) + team_filter: Final = ( + f" AND customer_keys.team_id = ANY(${len(params) + 1}::text[])" if scope.team_ids is not None else "" + ) + rows_filter: Final = ( + f" AND id = ANY(${len(params) + int(scope.team_ids is not None) + 1}::text[])" if row_ids is not None else "" + ) + return SqlQuery( + sql=f'''WITH customer_keys AS MATERIALIZED ({_customer_key_teams_sql(scope)}) + 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 daily.*, customer_keys.team_id AS customer_team_id + FROM "{PRISMA_TO_PG_TABLE[scope.table]}" daily JOIN customer_keys USING (api_key) + WHERE {where_clause}{team_filter}{rows_filter}) scoped + GROUP BY date, customer_team_id''', + params=( + *params, + *((list(scope.team_ids),) if scope.team_ids is not None else ()), + *((list(row_ids),) if row_ids is not None else ()), + ), + ) + + +def build_customer_daily_rows_sql(scope: DailyActivityScope, *, page: int, page_size: int) -> SqlQuery: + where_clause, params = build_where_clause(scope) + return SqlQuery( + sql=f'''WITH filtered AS MATERIALIZED ( + SELECT * FROM "{PRISMA_TO_PG_TABLE[scope.table]}" WHERE {where_clause} + ) + SELECT (SELECT COUNT(*) FROM filtered)::bigint AS total_count, + COALESCE((SELECT jsonb_agg(page_rows) FROM ( + SELECT * FROM filtered ORDER BY date DESC, id ASC + OFFSET ${len(params) + 1} LIMIT ${len(params) + 2} + ) page_rows), '[]'::jsonb) AS rows''', + params=(*params, (page - 1) * page_size, page_size), + ) + + 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/repositories/key_metadata_sql.py b/litellm/repositories/key_metadata_sql.py new file mode 100644 index 00000000000..c92974ea1a2 --- /dev/null +++ b/litellm/repositories/key_metadata_sql.py @@ -0,0 +1,15 @@ +from typing import Final + +ACTIVE_TOKEN_DIGEST_SQL: Final = """ +SELECT encode(sha256(convert_to(token, 'UTF8')), 'hex') AS digest, key_alias, team_id, user_id +FROM "LiteLLM_VerificationToken" +WHERE encode(sha256(convert_to(token, 'UTF8')), 'hex') = ANY($1::text[]) +""" + +DELETED_TOKEN_DIGEST_SQL: Final = """ +SELECT DISTINCT ON (token) + encode(sha256(convert_to(token, 'UTF8')), 'hex') AS digest, key_alias, team_id, user_id +FROM "LiteLLM_DeletedVerificationToken" +WHERE encode(sha256(convert_to(token, 'UTF8')), 'hex') = ANY($1::text[]) +ORDER BY token, deleted_at DESC +""" 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..4c78af44e25 --- /dev/null +++ b/tests/integration/spend/test_customer_spend_by_team.py @@ -0,0 +1,185 @@ +import uuid +from hashlib import sha256 +from datetime import datetime, timedelta, timezone +from time import perf_counter +from typing import Final + +import pytest +from integration._support.client import Gateway, eventually, string_value +from integration._support.database import read_rows, write_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} + stored_key: Final = sha256(key_a.encode()).hexdigest() + stored_model: Final = str( + read_rows( + 'SELECT model FROM "LiteLLM_DailyEndUserSpend" WHERE end_user_id=%s AND api_key=%s', + (customer, stored_key), + )[0]["model"] + ) + combined: Final = gateway.request( + "GET", + f"{query}&team_ids={team_a}&api_key={stored_key}&model={stored_model}", + ) + assert combined.status_code == 200, combined.text + assert SpendAnalyticsPaginatedResponse.model_validate_json( + combined.content + ).metadata.total_spend == pytest.approx(20 * 0.001 + 20 * 0.002) + excluded: Final = gateway.request( + "GET", + f"{query}&team_ids={team_b}&api_key={stored_key}&model={stored_model}", + ) + assert excluded.status_code == 200, excluded.text + assert SpendAnalyticsPaginatedResponse.model_validate_json(excluded.content).metadata.total_spend == 0 + 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} + for key, team in ((key_a, team_a), (key_b, team_b)): + legacy_key: Final = sha256(sha256(key.encode()).hexdigest().encode()).hexdigest() + legacy_customer: Final = f"legacy-{uuid.uuid4().hex}" + scenario.cleanups.callback( + write_rows, 'DELETE FROM "LiteLLM_DailyEndUserSpend" WHERE end_user_id=%s', (legacy_customer,) + ) + write_rows( + 'INSERT INTO "LiteLLM_DailyEndUserSpend" ' + "(id,end_user_id,date,api_key,model,spend,api_requests,updated_at) " + "VALUES (gen_random_uuid()::text,%s,%s,%s,%s,0.25,1,now())", + (legacy_customer, today.isoformat(), legacy_key, stored_model), + ) + legacy_query: Final = f"{route}?end_user_ids={legacy_customer}&start_date={start}&end_date={end}" + for suffix in ("", f"&team_ids={team}"): + legacy: Final = gateway.request("GET", legacy_query + suffix) + assert legacy.status_code == 200, legacy.text + legacy_body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(legacy.content) + assert legacy_body.metadata.total_spend == 0.25 + legacy_teams: Final = legacy_body.results[0].breakdown.teams or {} + assert set(legacy_teams) == {team} + assert legacy_teams[team].metrics.spend == legacy_body.metadata.total_spend + + +@pytest.mark.parametrize("route", ("/customer/daily/activity", "/customer/daily/activity/aggregated")) +def test_large_customer_team_reports_keep_filters_and_pagination(gateway: Gateway, route: str) -> None: + with gateway.scenario() as scenario: + team: Final = scenario.team(team_alias="large-customer-team") + customer: Final = f"large-customer-{uuid.uuid4().hex}" + scenario.cleanups.callback(write_rows, 'DELETE FROM "LiteLLM_VerificationToken" WHERE team_id=%s', (team,)) + scenario.cleanups.callback( + write_rows, 'DELETE FROM "LiteLLM_DailyEndUserSpend" WHERE end_user_id=%s', (customer,) + ) + write_rows( + 'INSERT INTO "LiteLLM_VerificationToken" (token,models,team_id) ' + "SELECT encode(sha256(convert_to(%s||i,'UTF8')),'hex'),ARRAY[]::text[],%s " + "FROM generate_series(1,33000) i", + (customer, team), + ) + write_rows( + 'INSERT INTO "LiteLLM_DailyEndUserSpend" ' + "(id,end_user_id,date,api_key,model,spend,api_requests,updated_at) " + "SELECT %s||lpad(i::text,5,'0'),%s,'2026-02-03'," + "encode(sha256(convert_to(%s||i,'UTF8')),'hex')," + "CASE WHEN i%%2=1 THEN 'large-model-a' ELSE 'large-model-b' END," + "CASE WHEN i%%2=1 THEN 0.25 ELSE 0.5 END,1,now() FROM generate_series(1,33000) i", + (customer, customer, customer), + ) + write_rows('ANALYZE "LiteLLM_VerificationToken"', ()) + write_rows('ANALYZE "LiteLLM_DeletedVerificationToken"', ()) + write_rows('ANALYZE "LiteLLM_DailyEndUserSpend"', ()) + key: Final = sha256(f"{customer}1".encode()).hexdigest() + base: Final = ( + f"{route}?end_user_ids={customer}&team_ids={team}&start_date=2026-02-03&end_date=2026-02-03&page_size=10" + ) + aggregated: Final = route.endswith("aggregated") + for suffix, spend, pages in ( + ("", 12375.0 if aggregated else 3.75, 3300), + ("&model=large-model-a", 4125.0 if aggregated else 2.5, 1650), + (f"&api_key={key}", 0.25, 1), + (f"&api_key={key}&model=large-model-a", 0.25, 1), + (f"&api_key={key}&model=large-model-b", 0.0, 0), + ): + started: Final = perf_counter() + response: Final = gateway.request("GET", base + suffix) + print(f"large-team {route} {suffix!r}: {response.status_code}, {perf_counter() - started:.3f}s") + assert response.status_code == 200, response.text + body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(response.content) + assert body.metadata.total_spend == spend + assert ( + sum( + sum(bucket.metrics.spend for bucket in (day.breakdown.teams or {}).values()) for day in body.results + ) + == spend + ) + if not aggregated: + assert body.metadata.total_pages == pages + assert body.metadata.has_more == (pages > 1) + if not aggregated: + second: Final = gateway.request("GET", base + "&page=2") + assert second.status_code == 200, second.text + second_body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(second.content) + assert second_body.metadata.total_spend == 3.75 + expected_keys: Final = {sha256(f"{customer}{i}".encode()).hexdigest() for i in range(11, 21)} + assert set(second_body.results[0].breakdown.api_keys) == expected_keys 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..bba294b571e 100644 --- a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py @@ -1,5 +1,5 @@ from collections.abc import Mapping, Sequence -from dataclasses import asdict, fields +from dataclasses import asdict, fields, replace from datetime import date, datetime from types import FunctionType, SimpleNamespace from typing import Final @@ -41,7 +41,9 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( ) from litellm.types.repositories.daily_activity import ( AggregatedRows, + CustomerTeamRollupRow, DailyActivityScope, + DailyActivityTable, EntityRollupRow, GroupingSetsRow, KeyMetadataRow, @@ -222,6 +224,56 @@ async def test_usage_keeps_spend_when_the_guardrail_lookup_hits_a_database_failu assert result.results[0].breakdown.guardrails == {} +@pytest.mark.asyncio +@DATABASE_FAILURES +async def test_customer_usage_keeps_spend_when_the_team_lookup_hits_a_database_failure(failure: Exception) -> None: + repository: Final = _usage_repository() + repository.customer_team_spend = AsyncMock(side_effect=failure) + scope: Final = replace(_usage_scope(), table=DailyActivityTable.CUSTOMER, entity_id_field="end_user_id") + + result: Final = await _get_daily_activity_aggregated(repository, scope) + + assert result.metadata.total_spend == result.results[0].metrics.spend == 2.0 + assert result.results[0].breakdown.api_keys["key"].metrics.spend == 2.0 + assert result.results[0].breakdown.teams == {} + + +@pytest.mark.asyncio +@DATABASE_FAILURES +async def test_customer_daily_activity_keeps_spend_when_the_team_lookup_hits_a_database_failure( + failure: Exception, +) -> None: + prisma: Final = _daily_user_spend_prisma(AsyncMock(return_value=[])) + prisma.db.litellm_dailyenduserspend.count = AsyncMock(return_value=1) + prisma.db.litellm_dailyenduserspend.find_many = AsyncMock( + return_value=[ + SimpleNamespace( + **vars(_daily_user_spend_record(user_id="user", api_key="key", spend=7.0)), + end_user_id="customer", + id="row", + ) + ] + ) + prisma.db.query_raw = AsyncMock(side_effect=failure) + + result: Final = await get_daily_activity( + prisma_client=prisma, + table_name="litellm_dailyenduserspend", + entity_id_field="end_user_id", + entity_id=None, + entity_metadata_field=None, + start_date="2024-01-01", + end_date="2024-01-01", + model=None, + api_key=None, + page=1, + page_size=1000, + ) + + assert result.metadata.total_spend == result.results[0].metrics.spend == 7.0 + assert result.results[0].breakdown.teams == {} + + @pytest.mark.asyncio @pytest.mark.parametrize("failure", REJECTED_QUERIES) async def test_usage_keeps_spend_when_the_guardrail_table_rejects_the_query(failure: Exception) -> None: @@ -3053,3 +3105,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_repository.py b/tests/unit/repositories/test_daily_activity_repository.py index d93a75476a2..b68bb948b7b 100644 --- a/tests/unit/repositories/test_daily_activity_repository.py +++ b/tests/unit/repositories/test_daily_activity_repository.py @@ -1,5 +1,5 @@ from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import datetime, timezone from typing import Final @@ -662,6 +662,36 @@ async def test_daily_rows_selects_the_table_and_applies_filters_and_pagination( assert sum(len(daily_table.find_many_calls) for daily_table in tables.values()) == 1 +@pytest.mark.asyncio +@pytest.mark.parametrize("empty", (False, True)) +async def test_customer_team_page_returns_loaded_metrics_and_retains_count_on_empty_pages(empty: bool) -> None: + stored: Final = { + **_key_spend_row("key-a"), + "id": "row-a", + "end_user_id": "customer-a", + "date": "2026-01-01", + "compression_saved_tokens": 2, + "compression_savings_spend": 0.5, + "prompt_caching_savings_spend": 0.25, + "gateway_injected_caching_savings_spend": 0.125, + "autorouter_savings_spend": 0.0, + "total_response_time_ms": 10, + "timed_requests": 1, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + } + database: Final = _FakeDatabase((({"total_count": 33000, "rows": [] if empty else [stored]},),)) + repository, _ = _repository(database) + scope: Final = replace(_scope(table=DailyActivityTable.CUSTOMER), team_ids=("team-a",)) + + result: Final = await repository.daily_rows(scope, page=3301 if empty else 1, page_size=10) + + assert result.total_count == 33000 + assert tuple((row.id, row.api_key, row.spend, row.prompt_tokens) for row in result.rows) == ( + () if empty else (("row-a", "key-a", 1.0, 10),) + ) + + @pytest.mark.asyncio async def test_daily_rows_exclusion_without_entity_filter_keeps_null_entity_rows() -> None: database = _FakeDatabase() 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 634ef66f36f..c9a8abf1200 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -28768,6 +28768,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: { @@ -59758,6 +59765,7 @@ export interface operations { page?: number; page_size?: number; exclude_end_user_ids?: string | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -59796,6 +59804,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; @@ -59835,6 +59844,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; @@ -59875,6 +59885,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; @@ -59914,6 +59925,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; @@ -59953,6 +59965,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; @@ -60809,6 +60822,7 @@ export interface operations { page?: number; page_size?: number; exclude_end_user_ids?: string | null; + team_ids?: string | null; }; header?: never; path?: never; @@ -60847,6 +60861,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; @@ -60886,6 +60901,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; @@ -60926,6 +60942,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; @@ -60965,6 +60982,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; @@ -61004,6 +61022,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;