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>
This commit is contained in:
kenneth-berri 2026-11-09 19:35:31 -08:00 • committed by GitHub
parent 16fad64e95
commit 7ccc0e0fff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 688 additions and 24 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<EntityUsageProps> = ({
<Panel icon={Server} title={t("usage.entityUsage.providerUsage")}>
<ProviderSpendBreakdown rows={providerSpend} />
{entityType === "customer" && <SpendByCustomerTeam response={spendDataRaw} />}
</Panel>
</div>
);

View file

@ -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<CustomerTeamSpend>[] = [
{
header: () => i18n.t("usage.teamUserSpend.columns.team"),
accessorKey: "teamAlias",
cell: ({ row }) => (
<div>
<div>{row.original.teamAlias}</div>
{row.original.teamAlias !== row.original.teamId && (
<div className="text-xs text-muted-foreground">{row.original.teamId}</div>
)}
</div>
),
},
{
header: () => i18n.t("usage.teamUserSpend.columns.spend"),
accessorKey: "spend",
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={6} />,
},
{ 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 (
<section className="grid gap-3">
<h3 className="font-semibold">{t("usage.customerTeamSpend.title")}</h3>
<p className="text-xs text-muted-foreground">{t("usage.customerTeamSpend.attributionNote")}</p>
<DataTable
columns={columns}
data={customerTeamSpend(response)}
getRowId={(row) => row.teamId}
noDataMessage={t("usage.customerTeamSpend.noData")}
size="compact"
/>
</section>
);
}

View file

@ -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([]);
});
});

View file

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

View file

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

View file

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