mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
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:
parent
16fad64e95
commit
7ccc0e0fff
20 changed files with 688 additions and 24 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,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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
15
litellm/repositories/key_metadata_sql.py
Normal file
15
litellm/repositories/key_metadata_sql.py
Normal 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
|
||||
"""
|
||||
|
|
@ -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
|
||||
|
|
|
|||
185
tests/integration/spend/test_customer_spend_by_team.py
Normal file
185
tests/integration/spend/test_customer_spend_by_team.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,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()
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue