mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
feat: add customer spend breakdown and filtering by current team
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
74cad3a2c3
commit
be1dc08c6a
18 changed files with 410 additions and 5 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
|
|||
SpendMetrics,
|
||||
)
|
||||
from litellm.types.repositories.daily_activity import (
|
||||
CustomerTeamRollupRow,
|
||||
DailyActivityScope,
|
||||
DailyActivityTable,
|
||||
EntityRollupRow,
|
||||
|
|
@ -967,6 +968,7 @@ async def get_daily_activity(
|
|||
include_current_utc_day: bool = False,
|
||||
resolve_entity_metadata: Callable[[Sequence[DailySpendRecord]], Awaitable[dict[str, dict[str, object]]]]
|
||||
| None = None,
|
||||
team_ids: tuple[str, ...] | None = None,
|
||||
) -> SpendAnalyticsPaginatedResponse:
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.db_not_connected_error.value})
|
||||
|
|
@ -981,7 +983,7 @@ async def get_daily_activity(
|
|||
)
|
||||
|
||||
try:
|
||||
scope: Final = daily_activity_scope(
|
||||
base_scope: Final = daily_activity_scope(
|
||||
table_name,
|
||||
entity_id_field,
|
||||
entity_id,
|
||||
|
|
@ -993,6 +995,7 @@ async def get_daily_activity(
|
|||
timezone_offset_minutes,
|
||||
include_current_utc_day,
|
||||
)
|
||||
scope: Final = replace(base_scope, team_ids=team_ids)
|
||||
repository: Final = daily_activity_repository(prisma_client)
|
||||
page_data: Final = await repository.daily_rows(scope, page=page, page_size=page_size)
|
||||
daily_spend_data: Final = page_data.rows
|
||||
|
|
@ -1014,11 +1017,19 @@ async def get_daily_activity(
|
|||
entity_id_field=entity_id_field,
|
||||
entity_metadata_field=resolved_entity_metadata,
|
||||
)
|
||||
customer_results: Final = (
|
||||
_with_customer_teams(
|
||||
aggregated["results"],
|
||||
await repository.customer_team_spend(scope, row_ids=tuple(row.id for row in daily_spend_data)),
|
||||
)
|
||||
if scope.table is DailyActivityTable.CUSTOMER
|
||||
else aggregated["results"]
|
||||
)
|
||||
metadata_metrics = aggregated["totals"]
|
||||
if metadata_metrics_func:
|
||||
metadata_metrics = metadata_metrics_func(daily_spend_data)
|
||||
return SpendAnalyticsPaginatedResponse(
|
||||
results=aggregated["results"],
|
||||
results=list(customer_results),
|
||||
metadata=DailySpendMetadata(
|
||||
total_spend=metadata_metrics.spend,
|
||||
total_flat_cost=metadata_metrics.flat_cost,
|
||||
|
|
@ -1143,6 +1154,30 @@ async def _user_entity_metadata(
|
|||
)
|
||||
|
||||
|
||||
def _with_customer_teams(
|
||||
results: Sequence[DailySpendData], rows: Sequence[CustomerTeamRollupRow]
|
||||
) -> tuple[DailySpendData, ...]:
|
||||
return tuple(
|
||||
day.model_copy(
|
||||
update={
|
||||
"breakdown": day.breakdown.model_copy(
|
||||
update={
|
||||
"teams": {
|
||||
row.team_id: MetricWithMetadata(
|
||||
metrics=_record_to_spend_metrics(row),
|
||||
metadata={"team_alias": row.team_alias},
|
||||
)
|
||||
for row in rows
|
||||
if row.date == day.date.isoformat()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
for day in results
|
||||
)
|
||||
|
||||
|
||||
async def get_daily_activity_aggregated(
|
||||
repository: DailyActivityRepository,
|
||||
scope: DailyActivityScope,
|
||||
|
|
@ -1190,13 +1225,18 @@ async def get_daily_activity_aggregated(
|
|||
api_key_metadata=entity_key_metadata,
|
||||
entity_metadata_field=MappingProxyType({**user_entity_metadata, **(entity_metadata_field or {})}),
|
||||
)
|
||||
results: Final = (
|
||||
base_results: Final = (
|
||||
list(await asyncio.to_thread(_with_guardrail_breakdown, aggregated["results"], guardrail_rows))
|
||||
if guardrail_rows
|
||||
else aggregated["results"]
|
||||
)
|
||||
results: Final = (
|
||||
_with_customer_teams(base_results, await repository.customer_team_spend(scope))
|
||||
if scope.table is DailyActivityTable.CUSTOMER
|
||||
else base_results
|
||||
)
|
||||
return SpendAnalyticsPaginatedResponse(
|
||||
results=results,
|
||||
results=list(results),
|
||||
metadata=DailySpendMetadata(
|
||||
total_spend=aggregated["totals"].spend,
|
||||
total_flat_cost=aggregated["totals"].flat_cost,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ from litellm.repositories.daily_activity_sql import (
|
|||
adjust_dates_for_timezone,
|
||||
build_aggregated_sql,
|
||||
build_cache_leakage_keys_sql,
|
||||
build_customer_filtered_keys_sql,
|
||||
build_customer_team_rollup_sql,
|
||||
build_entity_rollup_sql,
|
||||
build_export_sql,
|
||||
build_guardrail_spend_sql,
|
||||
|
|
@ -29,6 +31,7 @@ from litellm.repositories.daily_activity_sql import (
|
|||
from litellm.repositories.prisma_protocols import TableActions
|
||||
from litellm.types.repositories.daily_activity import (
|
||||
AggregatedRows,
|
||||
CustomerTeamRollupRow,
|
||||
DailyActivityProxyReads,
|
||||
DailyActivityRow,
|
||||
DailyActivityScope,
|
||||
|
|
@ -102,6 +105,8 @@ class DailyActivityDatabase(Protocol):
|
|||
|
||||
|
||||
_GROUPING_ADAPTER: Final = TypeAdapter(tuple[GroupingSetsRow, ...])
|
||||
_CUSTOMER_TEAM_ADAPTER: Final = TypeAdapter(tuple[CustomerTeamRollupRow, ...])
|
||||
_FILTERED_KEYS_ADAPTER: Final = TypeAdapter(tuple[dict[str, StrictStr], ...])
|
||||
_ENTITY_ADAPTER: Final = TypeAdapter(tuple[EntityRollupRow, ...])
|
||||
_GUARDRAIL_SPEND_ADAPTER: Final = TypeAdapter(tuple[GuardrailSpendRow, ...])
|
||||
_KEY_SPEND_ADAPTER: Final = TypeAdapter(tuple[KeySpendRow, ...])
|
||||
|
|
@ -341,6 +346,16 @@ class DailyActivityRepository:
|
|||
if scope.exclude_entity_ids
|
||||
else {}
|
||||
)
|
||||
filtered_keys: Final = (
|
||||
tuple(
|
||||
row["api_key"]
|
||||
for row in _FILTERED_KEYS_ADAPTER.validate_python(
|
||||
await self._query(build_customer_filtered_keys_sql(scope))
|
||||
)
|
||||
)
|
||||
if scope.table is DailyActivityTable.CUSTOMER and scope.team_ids is not None
|
||||
else scope.api_keys
|
||||
)
|
||||
conditions: Final = {
|
||||
"date": {"gte": adjusted_start, "lte": adjusted_end},
|
||||
**({scope.entity_id_field: {"in": list(scope.entity_ids)}} if scope.entity_ids is not None else {}),
|
||||
|
|
@ -352,7 +367,7 @@ class DailyActivityRepository:
|
|||
if scope.model
|
||||
else {}
|
||||
),
|
||||
**({"api_key": {"in": list(scope.api_keys)}} if scope.api_keys is not None else {}),
|
||||
**({"api_key": {"in": list(filtered_keys)}} if filtered_keys is not None else {}),
|
||||
}
|
||||
count, rows = await asyncio.gather(
|
||||
table.count(where=conditions),
|
||||
|
|
@ -364,3 +379,10 @@ class DailyActivityRepository:
|
|||
),
|
||||
)
|
||||
return DailyRowsPage(total_count=count, rows=tuple(rows))
|
||||
|
||||
async def customer_team_spend(
|
||||
self, scope: DailyActivityScope, *, row_ids: tuple[str, ...] | None = None
|
||||
) -> tuple[CustomerTeamRollupRow, ...]:
|
||||
return _CUSTOMER_TEAM_ADAPTER.validate_python(
|
||||
await self._query(build_customer_team_rollup_sql(scope, row_ids=row_ids))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tu
|
|||
exclusion_index: Final = entity_index + int(has_entity_array)
|
||||
model_index: Final = exclusion_index + int(bool(scope.exclude_entity_ids))
|
||||
api_keys_index: Final = model_index + int(bool(scope.model))
|
||||
team_index: Final = api_keys_index + int(bool(scope.api_keys))
|
||||
conditions: Final = (
|
||||
f"date >= ${start_index}",
|
||||
f"date <= ${start_index + 1}",
|
||||
|
|
@ -105,6 +106,11 @@ def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tu
|
|||
if scope.api_keys
|
||||
else ()
|
||||
),
|
||||
*(
|
||||
(f"{_customer_team_expression(scope)} = ANY(${team_index}::text[])",)
|
||||
if scope.table is DailyActivityTable.CUSTOMER and scope.team_ids is not None
|
||||
else ()
|
||||
),
|
||||
)
|
||||
params: Final = (
|
||||
adjusted_start,
|
||||
|
|
@ -113,10 +119,46 @@ def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tu
|
|||
*((list(scope.exclude_entity_ids),) if scope.exclude_entity_ids else ()),
|
||||
*((scope.model,) if scope.model else ()),
|
||||
*((list(scope.api_keys),) if scope.api_keys else ()),
|
||||
*((list(scope.team_ids),) if scope.table is DailyActivityTable.CUSTOMER and scope.team_ids is not None else ()),
|
||||
)
|
||||
return " AND ".join(conditions), params
|
||||
|
||||
|
||||
def _customer_team_expression(scope: DailyActivityScope) -> str:
|
||||
table: Final = PRISMA_TO_PG_TABLE[scope.table]
|
||||
return (
|
||||
"COALESCE(NULLIF((SELECT current_key.team_id FROM ("
|
||||
'SELECT token, team_id, 0 AS priority, NULL::timestamp AS deleted_at FROM "LiteLLM_VerificationToken" '
|
||||
"UNION ALL "
|
||||
'SELECT token, team_id, 1 AS priority, deleted_at FROM "LiteLLM_DeletedVerificationToken"'
|
||||
f') current_key WHERE current_key.token = "{table}".api_key '
|
||||
"ORDER BY priority, deleted_at DESC LIMIT 1), ''), 'Unassigned')"
|
||||
)
|
||||
|
||||
|
||||
def build_customer_team_rollup_sql(scope: DailyActivityScope, *, row_ids: tuple[str, ...] | None = None) -> SqlQuery:
|
||||
where_clause, params = build_where_clause(scope)
|
||||
team: Final = _customer_team_expression(scope)
|
||||
rows_filter: Final = f" AND id = ANY(${len(params) + 1}::text[])" if row_ids is not None else ""
|
||||
return SqlQuery(
|
||||
sql=f'''SELECT date, NULL::text AS api_key, customer_team_id AS team_id,
|
||||
(SELECT team_alias FROM "LiteLLM_TeamTable" WHERE team_id = scoped.customer_team_id) AS team_alias,
|
||||
{_rollup_metric_select(scope.table)}
|
||||
FROM (SELECT *, {team} AS customer_team_id FROM "{PRISMA_TO_PG_TABLE[scope.table]}"
|
||||
WHERE {where_clause}{rows_filter}) scoped
|
||||
GROUP BY date, customer_team_id''',
|
||||
params=(*params, *((list(row_ids),) if row_ids is not None else ())),
|
||||
)
|
||||
|
||||
|
||||
def build_customer_filtered_keys_sql(scope: DailyActivityScope) -> SqlQuery:
|
||||
where_clause, params = build_where_clause(scope)
|
||||
return SqlQuery(
|
||||
sql=f'SELECT DISTINCT api_key FROM "{PRISMA_TO_PG_TABLE[scope.table]}" WHERE {where_clause}',
|
||||
params=params,
|
||||
)
|
||||
|
||||
|
||||
def _ptu_flat_cost_select(table: DailyActivityTable, *, aggregate: bool = True) -> str:
|
||||
if table is DailyActivityTable.TEAM:
|
||||
return "SUM(ptu_flat_cost)::float AS ptu_flat_cost" if aggregate else "SUM(scoped.ptu_flat_cost)::float"
|
||||
|
|
|
|||
|
|
@ -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. "
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
78
tests/integration/spend/test_customer_spend_by_team.py
Normal file
78
tests/integration/spend/test_customer_spend_by_team.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from integration._support.client import Gateway, eventually, string_value
|
||||
from integration._support.database import read_rows
|
||||
|
||||
from litellm.types.proxy.management_endpoints.common_daily_activity import SpendAnalyticsPaginatedResponse
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
(
|
||||
"/customer/daily/activity",
|
||||
"/end_user/daily/activity",
|
||||
"/customer/daily/activity/aggregated",
|
||||
"/end_user/daily/activity/aggregated",
|
||||
),
|
||||
)
|
||||
def test_customer_spend_by_current_team_and_team_filter(gateway: Gateway, route: str) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002)
|
||||
team_a: Final = scenario.team(team_alias="customer-team-a")
|
||||
team_b: Final = scenario.team(team_alias="customer-team-b")
|
||||
key_a: Final = scenario.key(models=[model], team_id=team_a)
|
||||
key_b: Final = string_value(gateway.post("/key/generate", {"models": [model], "team_id": team_b})["key"])
|
||||
scenario.cleanups.callback(gateway.request, "POST", "/key/delete", {"keys": [key_b]})
|
||||
key_unassigned: Final = scenario.key(models=[model])
|
||||
customer: Final = f"customer-{uuid.uuid4().hex}"
|
||||
scenario.cleanups.callback(gateway.request, "POST", "/customer/delete", {"user_ids": [customer]})
|
||||
for index, key in enumerate((key_a, key_b, key_unassigned)):
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
{"model": model, "messages": [{"role": "user", "content": f"hello {index}"}], "user": customer},
|
||||
key=key,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
eventually(
|
||||
lambda: read_rows(
|
||||
'SELECT SUM(api_requests)::int AS requests FROM "LiteLLM_DailyEndUserSpend" WHERE end_user_id=%s',
|
||||
(customer,),
|
||||
),
|
||||
lambda rows: rows[0]["requests"] == 3,
|
||||
seconds=70,
|
||||
)
|
||||
today: Final = datetime.now(timezone.utc).date()
|
||||
start: Final = today - timedelta(days=1)
|
||||
end: Final = today + timedelta(days=1)
|
||||
query: Final = f"{route}?end_user_ids={customer}&start_date={start}&end_date={end}&page_size=100"
|
||||
result: Final = gateway.request("GET", query)
|
||||
assert result.status_code == 200, result.text
|
||||
body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(result.content)
|
||||
assert body.metadata.total_spend == pytest.approx(3 * (20 * 0.001 + 20 * 0.002))
|
||||
assert len(body.results) == 1
|
||||
teams: Final = body.results[0].breakdown.teams
|
||||
assert teams is not None
|
||||
assert set(teams) == {team_a, team_b, "Unassigned"}
|
||||
assert teams[team_a].metadata["team_alias"] == "customer-team-a"
|
||||
assert teams[team_b].metadata["team_alias"] == "customer-team-b"
|
||||
assert sum(bucket.metrics.spend for bucket in teams.values()) == pytest.approx(body.metadata.total_spend)
|
||||
assert all(bucket.metrics.api_requests == 1 for bucket in teams.values())
|
||||
assert all(bucket.metrics.total_tokens == 40 for bucket in teams.values())
|
||||
filtered: Final = gateway.request("GET", f"{query}&team_ids={team_a}")
|
||||
assert filtered.status_code == 200, filtered.text
|
||||
filtered_body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(filtered.content)
|
||||
assert filtered_body.metadata.total_spend == pytest.approx(20 * 0.001 + 20 * 0.002)
|
||||
assert set(filtered_body.results[0].breakdown.teams or {}) == {team_a}
|
||||
empty: Final = gateway.request("GET", f"{query}&team_ids=missing-team")
|
||||
assert empty.status_code == 200, empty.text
|
||||
assert SpendAnalyticsPaginatedResponse.model_validate_json(empty.content).metadata.total_spend == 0
|
||||
scenario.delete_key(key_b)
|
||||
deleted: Final = gateway.request("GET", f"{query}&team_ids={team_b}")
|
||||
assert deleted.status_code == 200, deleted.text
|
||||
deleted_body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(deleted.content)
|
||||
assert deleted_body.metadata.total_spend == pytest.approx(20 * 0.001 + 20 * 0.002)
|
||||
assert set(deleted_body.results[0].breakdown.teams or {}) == {team_b}
|
||||
|
|
@ -41,6 +41,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
|
|||
)
|
||||
from litellm.types.repositories.daily_activity import (
|
||||
AggregatedRows,
|
||||
CustomerTeamRollupRow,
|
||||
DailyActivityScope,
|
||||
EntityRollupRow,
|
||||
GroupingSetsRow,
|
||||
|
|
@ -3053,3 +3054,34 @@ def test_metric_merges_preserve_every_additive_field() -> None:
|
|||
metrics: Final = SpendMetrics.model_validate(dict.fromkeys(SpendMetrics.model_fields, 2))
|
||||
merged: Final = common_daily_activity_module.sum_spend_metrics((SpendMetrics(), metrics, metrics))
|
||||
assert merged.model_dump() == dict.fromkeys(SpendMetrics.model_fields, 4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("team_ids", (("team-a", "Unassigned"), ()))
|
||||
def test_customer_teams_preserve_metrics_and_unassigned_bucket(team_ids: tuple[str, ...]) -> None:
|
||||
from litellm.types.proxy.management_endpoints.common_daily_activity import DailySpendData
|
||||
|
||||
rows: Final = tuple(
|
||||
CustomerTeamRollupRow(
|
||||
date="2026-01-01",
|
||||
api_key=None,
|
||||
team_id=team_id,
|
||||
team_alias="Team A" if team_id == "team-a" else None,
|
||||
**{
|
||||
field.name: 2
|
||||
for field in fields(CustomerTeamRollupRow)
|
||||
if field.name not in ("date", "api_key", "team_id", "team_alias")
|
||||
},
|
||||
)
|
||||
for team_id in team_ids
|
||||
)
|
||||
day: Final = DailySpendData(date=date(2026, 1, 1), metrics=SpendMetrics())
|
||||
results: Final = common_daily_activity_module._with_customer_teams((day,), rows)
|
||||
teams: Final = results[0].breakdown.teams
|
||||
assert teams is not None
|
||||
assert tuple(teams) == team_ids
|
||||
for team_id in team_ids:
|
||||
assert teams[team_id].metrics.spend == 2
|
||||
assert teams[team_id].metrics.api_requests == 2
|
||||
assert teams[team_id].metrics.total_tokens == 4
|
||||
assert teams[team_id].metadata == {"team_alias": "Team A" if team_id == "team-a" else None}
|
||||
assert "teams" not in day.breakdown.model_dump()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import Final
|
||||
|
||||
|
|
@ -80,6 +81,21 @@ def test_where_clause_binds_each_filter_as_a_single_array_parameter() -> None:
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("teams", (("team-a", "team-b"), (), ("Unassigned",)))
|
||||
def test_customer_team_filter_is_separate_from_customer_and_key_filters(teams: tuple[str, ...]) -> None:
|
||||
scope: Final = replace(
|
||||
_scope(table=DailyActivityTable.CUSTOMER, api_keys=("key-a",), model="model-a"), team_ids=teams
|
||||
)
|
||||
sql, params = build_where_clause(scope)
|
||||
assert '"end_user_id" = ANY($3::text[])' in sql
|
||||
assert "api_key = ANY($5::text[])" in sql
|
||||
assert "= ANY($6::text[])" in sql
|
||||
assert '"LiteLLM_VerificationToken"' in sql
|
||||
assert '"LiteLLM_DeletedVerificationToken"' in sql
|
||||
assert "ORDER BY priority, deleted_at DESC LIMIT 1" in sql
|
||||
assert params == ("2026-01-01", "2026-01-31", ["user-1"], "model-a", ["key-a"], list(teams))
|
||||
|
||||
|
||||
def test_where_clause_exclusion_keeps_null_entity_rows() -> None:
|
||||
scope = _scope(table=DailyActivityTable.TEAM, entity_ids=None, exclude_entity_ids=("litellm-dashboard",))
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
19
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
19
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -28757,6 +28757,13 @@ export interface components {
|
|||
providers?: {
|
||||
[key: string]: components["schemas"]["MetricWithMetadata"];
|
||||
};
|
||||
/**
|
||||
* Teams
|
||||
* @description Customer spend by the key's current team, or last team for deleted keys. Keys without a known team are grouped under Unassigned
|
||||
*/
|
||||
teams?: {
|
||||
[key: string]: components["schemas"]["MetricWithMetadata"];
|
||||
} | null;
|
||||
};
|
||||
/** BudgetConfig */
|
||||
BudgetConfig: {
|
||||
|
|
@ -59743,6 +59750,7 @@ export interface operations {
|
|||
page?: number;
|
||||
page_size?: number;
|
||||
exclude_end_user_ids?: string | null;
|
||||
team_ids?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -59781,6 +59789,7 @@ export interface operations {
|
|||
api_key?: string | null;
|
||||
exclude_end_user_ids?: string | null;
|
||||
timezone?: number | null;
|
||||
team_ids?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -59820,6 +59829,7 @@ export interface operations {
|
|||
api_key?: string | null;
|
||||
exclude_end_user_ids?: string | null;
|
||||
timezone?: number | null;
|
||||
team_ids?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -59860,6 +59870,7 @@ export interface operations {
|
|||
api_key?: string | null;
|
||||
exclude_end_user_ids?: string | null;
|
||||
timezone?: number | null;
|
||||
team_ids?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -59899,6 +59910,7 @@ export interface operations {
|
|||
api_key?: string | null;
|
||||
exclude_end_user_ids?: string | null;
|
||||
timezone?: number | null;
|
||||
team_ids?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -59938,6 +59950,7 @@ export interface operations {
|
|||
api_key?: string | null;
|
||||
exclude_end_user_ids?: string | null;
|
||||
timezone?: number | null;
|
||||
team_ids?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -60794,6 +60807,7 @@ export interface operations {
|
|||
page?: number;
|
||||
page_size?: number;
|
||||
exclude_end_user_ids?: string | null;
|
||||
team_ids?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -60832,6 +60846,7 @@ export interface operations {
|
|||
api_key?: string | null;
|
||||
exclude_end_user_ids?: string | null;
|
||||
timezone?: number | null;
|
||||
team_ids?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -60871,6 +60886,7 @@ export interface operations {
|
|||
api_key?: string | null;
|
||||
exclude_end_user_ids?: string | null;
|
||||
timezone?: number | null;
|
||||
team_ids?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -60911,6 +60927,7 @@ export interface operations {
|
|||
api_key?: string | null;
|
||||
exclude_end_user_ids?: string | null;
|
||||
timezone?: number | null;
|
||||
team_ids?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -60950,6 +60967,7 @@ export interface operations {
|
|||
api_key?: string | null;
|
||||
exclude_end_user_ids?: string | null;
|
||||
timezone?: number | null;
|
||||
team_ids?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
@ -60989,6 +61007,7 @@ export interface operations {
|
|||
api_key?: string | null;
|
||||
exclude_end_user_ids?: string | null;
|
||||
timezone?: number | null;
|
||||
team_ids?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue