diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 34d7fc1e0f0..3d8d701d15b 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -310,7 +310,6 @@ class KeyManagementRoutes(str, enum.Enum): # team usage routes TEAM_DAILY_ACTIVITY = "/team/daily/activity" TEAM_DAILY_ACTIVITY_AGGREGATED = "/team/daily/activity/aggregated" - TEAM_DAILY_ACTIVITY_EXPORT = "/team/daily/activity/export" TEAM_DAILY_ACTIVITY_AGGREGATED_SEARCH = "/team/daily/activity/aggregated/search" # team spend-log viewing @@ -724,7 +723,6 @@ class LiteLLMRoutes(enum.Enum): "/team/permissions_bulk_update", "/team/daily/activity", "/team/daily/activity/aggregated", - "/team/daily/activity/export", "/team/daily/activity/aggregated/search", "/team/spend/by_user", # gateway request counts (SGR); deployment-wide, admin-only @@ -897,7 +895,6 @@ class LiteLLMRoutes(enum.Enum): "/team/permissions_update", "/team/daily/activity", "/team/daily/activity/aggregated", - "/team/daily/activity/export", "/team/daily/activity/aggregated/search", "/team/spend/by_user", "/team/{team_id}/members/me", @@ -998,7 +995,6 @@ class LiteLLMRoutes(enum.Enum): "/user/daily/activity", "/team/daily/activity", "/team/daily/activity/aggregated", - "/team/daily/activity/export", "/team/daily/activity/aggregated/search", "/tag/daily/activity", "/tag/list", diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 1c674d3640c..086e8253f31 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -1,6 +1,4 @@ import asyncio -import dataclasses -import itertools from collections.abc import Awaitable, Callable, Mapping, Sequence from collections.abc import Set as AbstractSet from datetime import datetime, timedelta, timezone @@ -38,10 +36,6 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, SpendMetrics, ) -from litellm.types.proxy.management_endpoints.team_endpoints import ( - TeamDailyActivityExportRow, - TeamDailyActivityExportType, -) if TYPE_CHECKING: from prisma.models import ( @@ -205,7 +199,7 @@ class _AggregatedQueryKwargs(TypedDict): include_current_utc_day: ReadOnly[bool] -_SqlQuery = tuple[str, Sequence[str]] +_SqlQuery = tuple[str, list[str]] async def _query_raw_optional( @@ -982,291 +976,6 @@ def _build_entity_rollup_sql_query( return sql_query, sql_params -def _build_export_sql_query( - *, - table_name: str, - entity_id_field: str, - entity_id: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - start_date: str, - end_date: str, - api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - exclude_entity_ids: list[str] | None, # mutable-ok: filter union shared with the paginated path - timezone_offset_minutes: int | None, - export_type: TeamDailyActivityExportType, -) -> tuple[str, tuple[str, ...]]: - """One unbounded rollup for the export route, on the aggregated path's WHERE clause. - - No LIMIT anywhere: the export exists so a caller can reach keys past - USAGE_TOP_API_KEYS_LIMIT. PTU sentinel rows stay in `daily` so per-team - totals match breakdown.entities, and are excluded from the key, user and - model exports where the flat-cost row has no meaning. - """ - pg_table: Final = _PRISMA_TO_PG_TABLE.get(table_name) - if pg_table is None: - raise ValueError(f"Unknown table name: {table_name}") - - adjusted_start, adjusted_end = _adjust_dates_for_timezone(start_date, end_date, timezone_offset_minutes) - where_clause, where_params = _build_aggregated_where_clause( - entity_id_field=entity_id_field, - entity_id=entity_id, - adjusted_start=adjusted_start, - adjusted_end=adjusted_end, - model=None, - api_key=api_key, - exclude_entity_ids=exclude_entity_ids, - ) - - keyed: Final = export_type in ("daily_with_keys", "daily_with_users") - by_model: Final = export_type == "daily_with_models" - group_extras: Final = tuple(field for field in ("api_key" if keyed else "", "model" if by_model else "") if field) - group_by: Final = f'date, "{entity_id_field}"' + "".join(f", {field}" for field in group_extras) - sentinel_clause: Final = f" AND api_key <> ${len(where_params) + 1}" if (keyed or by_model) else "" - sentinel_params: Final = (PTU_SENTINEL_API_KEY,) if (keyed or by_model) else () - - sql_query: Final = f""" - SELECT - date, - "{entity_id_field}" AS entity_id, - {"api_key" if keyed else "NULL::text AS api_key"}, - {"model" if by_model else "NULL::text AS model"},{_rollup_metric_select(table_name)} - FROM "{pg_table}" - WHERE {where_clause}{sentinel_clause} - GROUP BY {group_by} - ORDER BY {group_by} - """ - - return sql_query, (*where_params, *sentinel_params) - - -class _ExportRow(_RollupMetricsRow): - entity_id: str | None - model: str | None - - -def _export_team_alias(entity_metadata_field: Mapping[str, dict[str, object]] | None, entity_id: str) -> str | None: - alias: Final = _entity_metadata(entity_metadata_field, entity_id).get("team_alias") - return alias if isinstance(alias, str) else None - - -@dataclasses.dataclass(frozen=True, slots=True) -class _ExportMetrics: - spend: float - api_requests: int - successful_requests: int - failed_requests: int - total_tokens: int - prompt_tokens: int - completion_tokens: int - cache_read_input_tokens: int - cache_creation_input_tokens: int - - @classmethod - def from_record(cls, record: _RollupMetricsRow) -> "_ExportMetrics": - prompt_tokens: Final = record.prompt_tokens or 0 - completion_tokens: Final = record.completion_tokens or 0 - return cls( - spend=record.spend or 0.0, - api_requests=record.api_requests or 0, - successful_requests=record.successful_requests or 0, - failed_requests=record.failed_requests or 0, - total_tokens=prompt_tokens + completion_tokens, - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - cache_read_input_tokens=record.cache_read_input_tokens or 0, - cache_creation_input_tokens=record.cache_creation_input_tokens or 0, - ) - - @classmethod - def zero(cls) -> "_ExportMetrics": - return cls( - spend=0.0, - api_requests=0, - successful_requests=0, - failed_requests=0, - total_tokens=0, - prompt_tokens=0, - completion_tokens=0, - cache_read_input_tokens=0, - cache_creation_input_tokens=0, - ) - - def __add__(self, other: "_ExportMetrics") -> "_ExportMetrics": - return _ExportMetrics( - spend=self.spend + other.spend, - api_requests=self.api_requests + other.api_requests, - successful_requests=self.successful_requests + other.successful_requests, - failed_requests=self.failed_requests + other.failed_requests, - total_tokens=self.total_tokens + other.total_tokens, - prompt_tokens=self.prompt_tokens + other.prompt_tokens, - completion_tokens=self.completion_tokens + other.completion_tokens, - cache_read_input_tokens=self.cache_read_input_tokens + other.cache_read_input_tokens, - cache_creation_input_tokens=self.cache_creation_input_tokens + other.cache_creation_input_tokens, - ) - - -def _export_base_row( - record: _ExportRow, - entity_metadata_field: Mapping[str, dict[str, object]] | None, -) -> TeamDailyActivityExportRow: - entity_id: Final = record.entity_id or "Unassigned" - metrics: Final = _ExportMetrics.from_record(record) - return TeamDailyActivityExportRow( - date=record.date, - team_id=entity_id, - team_alias=_export_team_alias(entity_metadata_field, entity_id), - model=record.model, - spend=metrics.spend, - flat_cost=_reported_flat_cost(record), - api_requests=metrics.api_requests, - successful_requests=metrics.successful_requests, - failed_requests=metrics.failed_requests, - total_tokens=metrics.total_tokens, - prompt_tokens=metrics.prompt_tokens, - completion_tokens=metrics.completion_tokens, - cache_read_input_tokens=metrics.cache_read_input_tokens, - cache_creation_input_tokens=metrics.cache_creation_input_tokens, - ) - - -def _export_key_row( - record: _ExportRow, - entity_metadata_field: Mapping[str, dict[str, object]] | None, - api_key_metadata: Mapping[str, _KeyMetadataDict], -) -> TeamDailyActivityExportRow: - entity_id: Final = record.entity_id or "Unassigned" - metadata: Final = _key_metadata(api_key_metadata, record.api_key or "") - metrics: Final = _ExportMetrics.from_record(record) - return TeamDailyActivityExportRow( - date=record.date, - team_id=entity_id, - team_alias=_export_team_alias(entity_metadata_field, entity_id), - api_key=record.api_key, - key_alias=metadata.key_alias, - user_id=metadata.user_id, - user_email=metadata.user_email, - spend=metrics.spend, - api_requests=metrics.api_requests, - successful_requests=metrics.successful_requests, - failed_requests=metrics.failed_requests, - total_tokens=metrics.total_tokens, - prompt_tokens=metrics.prompt_tokens, - completion_tokens=metrics.completion_tokens, - cache_read_input_tokens=metrics.cache_read_input_tokens, - cache_creation_input_tokens=metrics.cache_creation_input_tokens, - ) - - -def _fold_export_users( - records: Sequence[_ExportRow], - entity_metadata_field: Mapping[str, dict[str, object]] | None, - api_key_metadata: Mapping[str, _KeyMetadataDict], -) -> tuple[TeamDailyActivityExportRow, ...]: - """Fold (date, team, api_key) rows into (date, team, user) rows.""" - - def bucket_of(record: _ExportRow) -> tuple[str, str, str]: - return ( - record.date, - record.entity_id or "Unassigned", - _key_metadata(api_key_metadata, record.api_key or "").user_id or "Unassigned", - ) - - key_sets: Final = MappingProxyType( - { - bucket: frozenset(record.api_key or "" for record in group) - for bucket, group in itertools.groupby(sorted(records, key=bucket_of), key=bucket_of) - } - ) - sums: Final[dict[tuple[str, str, str], _ExportMetrics]] = {} # mutable-ok: local fold accumulator - emails: Final[dict[tuple[str, str, str], str | None]] = {} # mutable-ok: local fold accumulator - for record in records: - metadata = _key_metadata(api_key_metadata, record.api_key or "") - bucket_key = bucket_of(record) - sums[bucket_key] = sums.get(bucket_key, _ExportMetrics.zero()) + _ExportMetrics.from_record(record) - emails.setdefault(bucket_key, metadata.user_email) - if emails[bucket_key] is None and metadata.user_email is not None: - emails[bucket_key] = metadata.user_email - return tuple( - _export_folded_user_row( - bucket_key, sums[bucket_key], emails[bucket_key], len(key_sets[bucket_key]), entity_metadata_field - ) - for bucket_key in sorted(sums) - ) - - -def _export_folded_user_row( - bucket_key: tuple[str, str, str], - metrics: _ExportMetrics, - user_email: str | None, - keys: int, - entity_metadata_field: Mapping[str, dict[str, object]] | None, -) -> TeamDailyActivityExportRow: - date, entity_id, user_id = bucket_key - return TeamDailyActivityExportRow( - date=date, - team_id=entity_id, - team_alias=_export_team_alias(entity_metadata_field, entity_id), - user_id=user_id if user_id != "Unassigned" else None, - user_email=user_email, - keys=keys, - spend=metrics.spend, - api_requests=metrics.api_requests, - successful_requests=metrics.successful_requests, - failed_requests=metrics.failed_requests, - total_tokens=metrics.total_tokens, - prompt_tokens=metrics.prompt_tokens, - completion_tokens=metrics.completion_tokens, - cache_read_input_tokens=metrics.cache_read_input_tokens, - cache_creation_input_tokens=metrics.cache_creation_input_tokens, - ) - - -async def get_daily_activity_export_rows( - *, - prisma_client: PrismaClient, - table_name: str, - entity_id_field: str, - entity_id: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - entity_metadata_field: Mapping[str, dict[str, object]] | None, - start_date: str, - end_date: str, - api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - exclude_entity_ids: list[str] | None, # mutable-ok: filter union shared with the paginated path - timezone_offset_minutes: int | None, - export_type: TeamDailyActivityExportType, -) -> tuple[TeamDailyActivityExportRow, ...]: - """Every (date, entity[, api_key|model]) rollup row in the range, uncapped.""" - sql_query, sql_params = _build_export_sql_query( - table_name=table_name, - entity_id_field=entity_id_field, - entity_id=entity_id, - start_date=start_date, - end_date=end_date, - api_key=api_key, - exclude_entity_ids=exclude_entity_ids, - timezone_offset_minutes=timezone_offset_minutes, - export_type=export_type, - ) - raw_rows: Final = await _query_raw_optional(prisma_client, (sql_query, sql_params)) - records: Final = tuple(_ExportRow(**row) for row in (raw_rows or ())) - - if export_type in ("daily", "daily_with_models"): - return await asyncio.to_thread( - lambda: tuple(_export_base_row(record, entity_metadata_field) for record in records) - ) - - api_keys: Final = frozenset(record.api_key for record in records if record.api_key) - api_key_metadata: Final = ( - await get_api_key_metadata(prisma_client, api_keys, _spend_logs_window(frozenset(r.date for r in records))) - if api_keys - else _EMPTY_KEY_METADATA - ) - if export_type == "daily_with_keys": - return await asyncio.to_thread( - lambda: tuple(_export_key_row(record, entity_metadata_field, api_key_metadata) for record in records) - ) - return await asyncio.to_thread(_fold_export_users, records, entity_metadata_field, api_key_metadata) - - def _aggregate_spend_records_sync( *, records: Sequence[DailySpendRecord], diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 8a6cd1218ee..493c83c730a 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -11,8 +11,6 @@ All /team management endpoints import asyncio import copy -import csv -import io import json import math import traceback @@ -35,8 +33,7 @@ from typing import ( ) import fastapi -from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status -from fastapi.responses import JSONResponse +from fastapi import APIRouter, Depends, Header, HTTPException, Request, status from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError from typing_extensions import ReadOnly, TypedDict, assert_never @@ -129,7 +126,6 @@ from litellm.proxy.hooks.model_max_budget_limiter import ( ) from litellm.proxy.management_endpoints.common_daily_activity import ( get_daily_activity_aggregated, - get_daily_activity_export_rows, ) from litellm.proxy.management_endpoints.common_utils import ( _check_disable_global_guardrails_caller_permission, @@ -210,11 +206,6 @@ from litellm.types.proxy.management_endpoints.team_endpoints import ( BulkUpdateTeamMemberPermissionsRequest, BulkUpdateTeamMemberPermissionsResponse, GetTeamMemberPermissionsResponse, - TeamDailyActivityExportFormat, - TeamDailyActivityExportMetadata, - TeamDailyActivityExportResponse, - TeamDailyActivityExportRow, - TeamDailyActivityExportType, TeamIdSearchFilter, TeamIdSearchMatch, TeamKeyActivitySearchWhere, @@ -6818,178 +6809,6 @@ async def get_team_daily_activity_aggregated( ) -_EXPORT_CSV_METRIC_HEADERS: Final = ( - "Spend ($)", - "Requests", - "Successful Requests", - "Failed Requests", - "Total Tokens", - "Prompt Tokens", - "Completion Tokens", - "Cache Read Input Tokens", - "Cache Creation Input Tokens", -) - - -def _export_csv_headers(export_type: TeamDailyActivityExportType) -> tuple[str, ...]: - base: Final = ("Date", "Team", "Team ID") - if export_type == "daily_with_keys": - return (*base, "Key Alias", "Key ID", "User ID", "User Email", *_EXPORT_CSV_METRIC_HEADERS) - if export_type == "daily_with_users": - return (*base, "User ID", "User Email", "Keys", *_EXPORT_CSV_METRIC_HEADERS) - if export_type == "daily_with_models": - return ( - *base, - "Model", - "Spend ($)", - "Requests", - "Successful", - "Failed", - "Total Tokens", - "Prompt Tokens", - "Completion Tokens", - "Cache Read Input Tokens", - "Cache Creation Input Tokens", - ) - return (*base, *_EXPORT_CSV_METRIC_HEADERS) - - -def _csv_safe(value: str) -> str: - return "'" + value if value[:1] in ("=", "+", "-", "@", "\t", "\r") else value - - -def _export_csv_record(row: TeamDailyActivityExportRow) -> dict[str, object]: - return { # mutable-ok: csv.DictWriter consumes a plain mapping per row - "Date": row.date, - "Team": _csv_safe(row.team_alias) if row.team_alias else "-", - "Team ID": row.team_id, - "Key Alias": _csv_safe(row.key_alias) if row.key_alias else "-", - "Key ID": row.api_key or "-", - "User ID": _csv_safe(row.user_id) if row.user_id else "-", - "User Email": _csv_safe(row.user_email) if row.user_email else "-", - "Keys": row.keys, - "Model": _csv_safe(row.model) if row.model else "-", - "Spend ($)": f"{row.spend:.4f}", - "Flat Cost ($)": f"{row.flat_cost:.4f}", - "Total Cost ($)": f"{row.spend + row.flat_cost:.4f}", - "Requests": row.api_requests, - "Successful Requests": row.successful_requests, - "Failed Requests": row.failed_requests, - "Successful": row.successful_requests, - "Failed": row.failed_requests, - "Total Tokens": row.total_tokens, - "Prompt Tokens": row.prompt_tokens, - "Completion Tokens": row.completion_tokens, - "Cache Read Input Tokens": row.cache_read_input_tokens, - "Cache Creation Input Tokens": row.cache_creation_input_tokens, - } - - -def _team_export_csv(export_type: TeamDailyActivityExportType, rows: Sequence[TeamDailyActivityExportRow]) -> str: - base_headers: Final = _export_csv_headers(export_type) - spend_index: Final = base_headers.index("Spend ($)") + 1 - headers: Final = ( - (*base_headers[:spend_index], "Flat Cost ($)", "Total Cost ($)", *base_headers[spend_index:]) - if sum(row.flat_cost for row in rows) > 0 - else base_headers - ) - buffer: Final = io.StringIO() - writer: Final = csv.DictWriter(buffer, fieldnames=headers, extrasaction="ignore") - writer.writeheader() - writer.writerows(_export_csv_record(row) for row in rows) - return buffer.getvalue() - - -@router.get( - "/team/daily/activity/export", - response_model=TeamDailyActivityExportResponse, - responses={200: {"content": {"text/csv": {}, "application/json": {}}}}, # mutable-ok: OpenAPI content map - tags=["team management"], # mutable-ok: fastapi's decorator signature types tags as a list -) -async def get_team_daily_activity_export( - user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], - start_date: str | None = None, - end_date: str | None = None, - export_type: TeamDailyActivityExportType = "daily", - format: TeamDailyActivityExportFormat = "csv", - team_id: str | None = None, - exclude_team_ids: str | None = None, - timezone_offset: Annotated[int | None, Query(alias="timezone")] = None, -) -> Response: - """ - Server-side Team Usage export, not subject to USAGE_TOP_API_KEYS_LIMIT. - - Same scoping as /team/daily/activity/aggregated, answered by one unbounded - rollup query, returned as CSV or JSON. For daily_with_keys, - daily_with_users and daily_with_models the PTU sentinel flat-cost rows are - excluded, so metadata totals under those export types cover request spend - only; the plain daily export includes them. - """ - from litellm.proxy.proxy_server import ( - prisma_client, - proxy_logging_obj, - user_api_key_cache, - ) - - if prisma_client is None: - raise _daily_activity_error(status_code=500, message=CommonProxyErrors.db_not_connected_error.value) - - range_error: Final = _aggregated_date_range_error(start_date, end_date) - if range_error is not None or start_date is None or end_date is None: - raise _daily_activity_error(status_code=400, message=range_error or "Please provide start_date and end_date") - - scope: Final = await _resolve_team_daily_activity_scope( - team_ids=team_id, - exclude_team_ids=exclude_team_ids, - api_key=None, - user_api_key_dict=user_api_key_dict, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - ) - - rows: Final = await get_daily_activity_export_rows( - prisma_client=prisma_client, - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=scope.team_ids, - entity_metadata_field=scope.team_alias_metadata, - start_date=start_date, - end_date=end_date, - api_key=scope.api_key_filter, - exclude_entity_ids=scope.exclude_team_ids, - timezone_offset_minutes=timezone_offset, - export_type=export_type, - ) - - now: Final = datetime.now(timezone.utc) - metadata: Final = TeamDailyActivityExportMetadata( - export_date=now.isoformat(), - export_type=export_type, - start_date=start_date, - end_date=end_date, - team_ids=list(scope.team_ids) if scope.team_ids else None, # mutable-ok: response model field type - total_spend=sum(row.spend for row in rows), - total_flat_cost=sum(row.flat_cost for row in rows), - total_api_requests=sum(row.api_requests for row in rows), - total_successful_requests=sum(row.successful_requests for row in rows), - total_failed_requests=sum(row.failed_requests for row in rows), - total_tokens=sum(row.total_tokens for row in rows), - ) - - if format == "json": - return JSONResponse( - content=TeamDailyActivityExportResponse(metadata=metadata, data=rows).model_dump(mode="json") - ) - return Response( - content=_team_export_csv(export_type, rows), - media_type="text/csv; charset=utf-8", - headers={ # mutable-ok: starlette Response headers is a dict - "Content-Disposition": f'attachment; filename="team_usage_{export_type}_{now.date().isoformat()}.csv"' - }, - ) - - def _team_key_search_where(*, search: str, scope: _TeamDailyActivityScope) -> TeamKeyActivitySearchWhere: """Caller scoping lives inside the same Prisma where as the search term so `take` never trims visible matches in favour of keys the caller is not allowed to see.""" diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 9b87d114a81..aac2703e918 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -277,48 +277,3 @@ class TeamUserSpendResponse(BaseModel): start_date: str end_date: str results: tuple[TeamUserSpendRow, ...] - - -TeamDailyActivityExportType = Literal["daily", "daily_with_keys", "daily_with_users", "daily_with_models"] -TeamDailyActivityExportFormat = Literal["csv", "json"] - - -class TeamDailyActivityExportRow(BaseModel): - date: str - team_id: str - team_alias: str | None = None - api_key: str | None = None - key_alias: str | None = None - user_id: str | None = None - user_email: str | None = None - keys: int | None = None - model: str | None = None - spend: float - flat_cost: float = 0.0 - api_requests: int - successful_requests: int - failed_requests: int - total_tokens: int - prompt_tokens: int - completion_tokens: int - cache_read_input_tokens: int - cache_creation_input_tokens: int - - -class TeamDailyActivityExportMetadata(BaseModel): - export_date: str - export_type: TeamDailyActivityExportType - start_date: str - end_date: str - team_ids: list[str] | None - total_spend: float - total_flat_cost: float = 0.0 - total_api_requests: int - total_successful_requests: int - total_failed_requests: int - total_tokens: int - - -class TeamDailyActivityExportResponse(BaseModel): - metadata: TeamDailyActivityExportMetadata - data: list[TeamDailyActivityExportRow] diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index e4574031d86..c2505977d87 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -28,7 +28,6 @@ GET /tag/user-agent/per-user-analytics GET /tag/wau GET /team/daily/activity GET /team/daily/activity/aggregated -GET /team/daily/activity/export GET /team/daily/activity/aggregated/search GET /team/spend/by_user GET /team/spend/report diff --git a/tests/integration/spend/test_team_daily_activity_export.py b/tests/integration/spend/test_team_daily_activity_export.py deleted file mode 100644 index b35d3fe0c8a..00000000000 --- a/tests/integration/spend/test_team_daily_activity_export.py +++ /dev/null @@ -1,522 +0,0 @@ -import csv -import io -import os -import signal -import uuid -from concurrent.futures import ThreadPoolExecutor -from datetime import datetime, timedelta, timezone -from hashlib import sha256 -from pathlib import Path -from typing import Final - -import httpx -import openai -import pytest -from integration._support.client import Gateway, Scenario, eventually, object_value, string_value -from integration._support.database import read_rows -from integration._support.process import group_members, owned_proxy, owned_proxy_process - - -def _export_range() -> dict[str, str]: - today: Final = datetime.now(timezone.utc) - return { - "start_date": (today - timedelta(days=1)).strftime("%Y-%m-%d"), - "end_date": (today + timedelta(days=1)).strftime("%Y-%m-%d"), - "timezone": "0", - } - - -def _team_with_three_keys( - gateway: Gateway, scenario: Scenario, model: str -) -> tuple[str, tuple[str, ...], tuple[str, ...], dict[str, float]]: - team: Final = scenario.team(models=[model]) - keys: Final = tuple(scenario.key(team_id=team, models=[model]) for _ in range(3)) - digests: Final = tuple(sha256(key.encode()).hexdigest() for key in keys) - for key in keys: - reply: Final = gateway.chat(model, key=key, text=f"team export {uuid.uuid4().hex}") - assert reply["usage"]["total_tokens"] == 40, reply - daily: Final = eventually( - lambda: read_rows('SELECT api_key, spend FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team,)), - lambda values: len({row["api_key"] for row in values}) == 3, - seconds=70, - ) - spend_by_key: Final = {row["api_key"]: float(row["spend"]) for row in daily} - return team, keys, digests, spend_by_key - - -def _export_json(gateway: Gateway, **params: str) -> httpx.Response: - return gateway.request("GET", "/team/daily/activity/export", params={**_export_range(), **params}) - - -def test_team_activity_export_returns_every_key_beyond_the_top_n_cap(gateway: Gateway) -> None: - with gateway.scenario() as scenario: - model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) - team: Final = scenario.team(models=[model]) - keys: Final = tuple(scenario.key(team_id=team, models=[model]) for _ in range(3)) - digests: Final = tuple(sha256(key.encode()).hexdigest() for key in keys) - for key in keys: - reply: Final = gateway.chat(model, key=key, text=f"team export {uuid.uuid4().hex}") - assert reply["usage"]["total_tokens"] == 40, reply - daily: Final = eventually( - lambda: read_rows('SELECT api_key, spend FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team,)), - lambda values: len({row["api_key"] for row in values}) == 3, - seconds=70, - ) - spend_by_key: Final = {row["api_key"]: float(row["spend"]) for row in daily} - response: Final = gateway.request( - "GET", - "/team/daily/activity/export", - params={ - **_export_range(), - "team_id": team, - "export_type": "daily_with_keys", - "format": "json", - }, - ) - assert response.status_code == 200, response.text - body: Final = object_value(response.json()) - rows: Final = tuple(object_value(row) for row in body["data"]) - assert sorted(string_value(row["api_key"]) for row in rows) == sorted(digests), response.text - for row in rows: - assert row["team_id"] == team, response.text - assert float(row["spend"]) == pytest.approx(spend_by_key[string_value(row["api_key"])]), response.text - metadata: Final = object_value(body["metadata"]) - assert ( - metadata["export_type"], - metadata["team_ids"], - metadata["total_api_requests"], - metadata["total_successful_requests"], - metadata["total_failed_requests"], - ) == ("daily_with_keys", [team], 3, 3, 0), response.text - assert float(metadata["total_spend"]) == pytest.approx(sum(spend_by_key.values())), response.text - - -def test_team_activity_export_csv_downloads_every_key(gateway: Gateway) -> None: - with gateway.scenario() as scenario: - model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) - team: Final = scenario.team(models=[model]) - keys: Final = tuple(scenario.key(team_id=team, models=[model]) for _ in range(3)) - digests: Final = tuple(sha256(key.encode()).hexdigest() for key in keys) - for key in keys: - reply: Final = gateway.chat(model, key=key, text=f"team export {uuid.uuid4().hex}") - assert reply["usage"]["total_tokens"] == 40, reply - daily: Final = eventually( - lambda: read_rows('SELECT api_key, spend FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team,)), - lambda values: len({row["api_key"] for row in values}) == 3, - seconds=70, - ) - spend_by_key: Final = {row["api_key"]: float(row["spend"]) for row in daily} - response: Final = gateway.request( - "GET", - "/team/daily/activity/export", - params={ - **_export_range(), - "team_id": team, - "export_type": "daily_with_keys", - "format": "csv", - }, - ) - assert response.status_code == 200, response.text - assert response.headers["content-type"].startswith("text/csv"), response.headers - assert "attachment" in response.headers["content-disposition"], response.headers - records: Final = tuple(csv.DictReader(io.StringIO(response.text))) - assert len(records) == 3, response.text - assert sorted(record["Key ID"] for record in records) == sorted(digests), response.text - assert sorted(record["Team ID"] for record in records) == [team, team, team], response.text - for record in records: - assert record["Spend ($)"] == f"{spend_by_key[record['Key ID']]:.4f}", response.text - - -def test_team_activity_export_denies_a_member_another_team(gateway: Gateway) -> 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(models=[model]) - team_b: Final = scenario.team(models=[model]) - member: Final = scenario.user(user_role="internal_user", teams=[team_a]) - member_key: Final = scenario.key(user_id=member, team_id=team_a, models=[model]) - reply: Final = gateway.chat(model, key=member_key, text=f"team export {uuid.uuid4().hex}") - assert reply["usage"]["total_tokens"] == 40, reply - daily: Final = eventually( - lambda: read_rows('SELECT api_key, spend FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team_a,)), - lambda values: len(values) == 1, - seconds=70, - ) - denied: Final = gateway.request( - "GET", - "/team/daily/activity/export", - params={**_export_range(), "team_id": team_b, "export_type": "daily", "format": "json"}, - key=member_key, - ) - assert denied.status_code == 404, denied.text - assert f"User does not belong to Team= {team_b}" in denied.text, denied.text - allowed: Final = gateway.request( - "GET", - "/team/daily/activity/export", - params={**_export_range(), "team_id": team_a, "export_type": "daily", "format": "json"}, - key=member_key, - ) - assert allowed.status_code == 200, allowed.text - rows: Final = tuple(object_value(row) for row in object_value(allowed.json())["data"]) - assert len(rows) == 1, allowed.text - assert rows[0]["team_id"] == team_a, allowed.text - assert float(rows[0]["spend"]) == pytest.approx(float(daily[0]["spend"])), allowed.text - - -def test_export_daily_total_matches_the_capped_aggregated_team_spend(gateway: Gateway, tmp_path: Path) -> None: - with owned_proxy(gateway, tmp_path, {"USAGE_TOP_API_KEYS_LIMIT": "2"}, workers=2) as candidate: - with candidate.scenario() as scenario: - model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) - team, keys, digests, spend_by_key = _team_with_three_keys(candidate, scenario, model) - aggregated: Final = candidate.request( - "GET", - "/team/daily/activity/aggregated", - params={**_export_range(), "team_ids": team}, - ) - assert aggregated.status_code == 200, aggregated.text - body: Final = object_value(aggregated.json()) - metadata: Final = object_value(body["metadata"]) - assert metadata["api_key_limit"] == 2, aggregated.text - assert metadata["total_api_keys"] == 3, aggregated.text - day: Final = object_value(body["results"][0]) - breakdown: Final = object_value(day["breakdown"]) - assert len(object_value(breakdown["api_keys"])) == 2, aggregated.text - team_spend: Final = float( - object_value(object_value(object_value(breakdown["entities"])[team])["metrics"])["spend"] - ) - - response: Final = _export_json(candidate, team_id=team, export_type="daily", format="json") - assert response.status_code == 200, response.text - rows: Final = tuple(object_value(row) for row in object_value(response.json())["data"]) - assert len(rows) == 1, response.text - assert rows[0]["team_id"] == team, response.text - assert float(rows[0]["spend"]) == pytest.approx(team_spend), response.text - assert float(rows[0]["spend"]) == pytest.approx(sum(spend_by_key.values())), response.text - - -def test_export_users_folds_spend_per_user_and_leaves_keyless_keys_unassigned(gateway: Gateway) -> None: - with gateway.scenario() as scenario: - model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) - team: Final = scenario.team(models=[model]) - user_a: Final = scenario.user(user_role="internal_user", teams=[team]) - user_b: Final = scenario.user(user_role="internal_user", teams=[team]) - key_a: Final = scenario.key(team_id=team, user_id=user_a, models=[model]) - key_b: Final = scenario.key(team_id=team, user_id=user_b, models=[model]) - key_none: Final = scenario.key(team_id=team, models=[model]) - for key in (key_a, key_b, key_none): - reply: Final = gateway.chat(model, key=key, text=f"team export {uuid.uuid4().hex}") - assert reply["usage"]["total_tokens"] == 40, reply - daily: Final = eventually( - lambda: read_rows('SELECT api_key, spend FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team,)), - lambda values: len({row["api_key"] for row in values}) == 3, - seconds=70, - ) - spend_by_key: Final = {row["api_key"]: float(row["spend"]) for row in daily} - response: Final = _export_json(gateway, team_id=team, export_type="daily_with_users", format="json") - assert response.status_code == 200, response.text - rows: Final = tuple(object_value(row) for row in object_value(response.json())["data"]) - by_user: Final = {row["user_id"]: row for row in rows} - assert by_user[user_a]["spend"] == pytest.approx(spend_by_key[sha256(key_a.encode()).hexdigest()]), ( - response.text - ) - assert by_user[user_b]["spend"] == pytest.approx(spend_by_key[sha256(key_b.encode()).hexdigest()]), ( - response.text - ) - assert None in by_user, response.text - assert by_user[None]["spend"] == pytest.approx(spend_by_key[sha256(key_none.encode()).hexdigest()]), ( - response.text - ) - metadata: Final = object_value(object_value(response.json())["metadata"]) - assert float(metadata["total_spend"]) == pytest.approx(sum(spend_by_key.values())), response.text - - -def test_export_models_reports_one_row_per_model_with_matching_spend(gateway: Gateway) -> None: - with gateway.scenario() as scenario: - upstream_a: Final = f"openai/export-{uuid.uuid4().hex}" - upstream_b: Final = f"openai/export-{uuid.uuid4().hex}" - model_a: Final = scenario.model(model=upstream_a, input_cost_per_token=0.001, output_cost_per_token=0.002) - model_b: Final = scenario.model(model=upstream_b, input_cost_per_token=0.0005, output_cost_per_token=0.001) - upstream_models: Final = (upstream_a, upstream_b) - team: Final = scenario.team(models=[model_a, model_b]) - key: Final = scenario.key(team_id=team, models=[model_a, model_b]) - for model in (model_a, model_b): - reply: Final = gateway.chat(model, key=key, text=f"team export {uuid.uuid4().hex}") - assert reply["usage"]["total_tokens"] == 40, reply - daily: Final = eventually( - lambda: read_rows('SELECT model, spend FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team,)), - lambda values: len({row["model"] for row in values}) == 2, - seconds=70, - ) - spend_by_model: Final = {row["model"]: float(row["spend"]) for row in daily} - - response: Final = _export_json(gateway, team_id=team, export_type="daily_with_models", format="json") - assert response.status_code == 200, response.text - rows: Final = tuple(object_value(row) for row in object_value(response.json())["data"]) - assert {row["model"] for row in rows} == set(upstream_models), response.text - for row in rows: - assert float(row["spend"]) == pytest.approx(spend_by_model[row["model"]]), response.text - - csv_response: Final = _export_json(gateway, team_id=team, export_type="daily_with_models", format="csv") - assert csv_response.status_code == 200, csv_response.text - records: Final = tuple(csv.DictReader(io.StringIO(csv_response.text))) - assert sorted(record["Model"] for record in records) == sorted(upstream_models), csv_response.text - - -def test_export_without_team_id_returns_only_the_callers_teams(gateway: Gateway) -> 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(models=[model]) - team_b: Final = scenario.team(models=[model]) - member: Final = scenario.user(user_role="internal_user", teams=[team_a]) - member_key: Final = scenario.key(user_id=member, team_id=team_a, models=[model]) - other_key: Final = scenario.key(team_id=team_b, models=[model]) - reply: Final = gateway.chat(model, key=member_key, text=f"team export {uuid.uuid4().hex}") - assert reply["usage"]["total_tokens"] == 40, reply - reply_b: Final = gateway.chat(model, key=other_key, text=f"team export {uuid.uuid4().hex}") - assert reply_b["usage"]["total_tokens"] == 40, reply_b - eventually( - lambda: read_rows('SELECT team_id FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team_b,)), - lambda values: len(values) == 1, - seconds=70, - ) - eventually( - lambda: read_rows('SELECT team_id FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team_a,)), - lambda values: len(values) == 1, - seconds=70, - ) - - response: Final = gateway.request( - "GET", - "/team/daily/activity/export", - params={**_export_range(), "export_type": "daily", "format": "json"}, - key=member_key, - ) - assert response.status_code == 200, response.text - rows: Final = tuple(object_value(row) for row in object_value(response.json())["data"]) - assert len(rows) == 1, response.text - assert rows[0]["team_id"] == team_a, response.text - - -def test_export_rejects_requests_without_a_valid_key(gateway: Gateway) -> None: - params: Final = {**_export_range(), "export_type": "daily", "format": "json"} - anonymous: Final = gateway.client.get("/team/daily/activity/export", params=params) - assert anonymous.status_code == 401, anonymous.text - garbage: Final = gateway.request("GET", "/team/daily/activity/export", params=params, key="sk-nope") - assert garbage.status_code == 401, garbage.text - - -def test_export_rejects_bad_parameters(gateway: Gateway) -> None: - weekly: Final = _export_json(gateway, export_type="weekly", format="json") - assert weekly.status_code == 422, weekly.text - xml: Final = _export_json(gateway, export_type="daily", format="xml") - assert xml.status_code == 422, xml.text - no_dates: Final = gateway.request( - "GET", "/team/daily/activity/export", params={"export_type": "daily", "format": "json"} - ) - assert no_dates.status_code == 400, no_dates.text - assert "start_date and end_date" in no_dates.text, no_dates.text - reversed_range: Final = gateway.request( - "GET", - "/team/daily/activity/export", - params={"start_date": "2026-09-25", "end_date": "2026-09-23", "export_type": "daily", "format": "json"}, - ) - assert reversed_range.status_code == 400, reversed_range.text - assert "end_date must be on or after start_date" in reversed_range.text, reversed_range.text - bad_date: Final = gateway.request( - "GET", - "/team/daily/activity/export", - params={"start_date": "2026-13-40", "end_date": "2026-12-31", "export_type": "daily", "format": "json"}, - ) - assert bad_date.status_code == 400, bad_date.text - assert "valid YYYY-MM-DD" in bad_date.text, bad_date.text - - -def test_export_of_a_team_without_spend_returns_empty(gateway: Gateway) -> None: - with gateway.scenario() as scenario: - model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) - team: Final = scenario.team(models=[model]) - fresh: Final = _export_json(gateway, team_id=team, export_type="daily", format="json") - assert fresh.status_code == 200, fresh.text - body: Final = object_value(fresh.json()) - assert body["data"] == [], fresh.text - assert float(object_value(body["metadata"])["total_spend"]) == 0, fresh.text - unknown: Final = _export_json(gateway, team_id=str(uuid.uuid4()), export_type="daily", format="json") - assert unknown.status_code == 200, unknown.text - unknown_body: Final = object_value(unknown.json()) - assert unknown_body["data"] == [], unknown.text - assert float(object_value(unknown_body["metadata"])["total_spend"]) == 0, unknown.text - - -def test_export_csv_is_deterministic_and_omits_flat_cost_without_ptu(gateway: Gateway) -> None: - with gateway.scenario() as scenario: - model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) - _team_with_three_keys(gateway, scenario, model) - params: Final = {**_export_range(), "export_type": "daily_with_keys", "format": "csv"} - first: Final = gateway.request("GET", "/team/daily/activity/export", params=params) - second: Final = gateway.request("GET", "/team/daily/activity/export", params=params) - assert first.status_code == 200 and second.status_code == 200, first.text - assert first.text == second.text, "daily_with_keys csv is not byte-identical across calls" - header: Final = first.text.splitlines()[0] - assert "Flat Cost" not in header and "Total Cost" not in header, header - - -def test_export_csv_escapes_formula_like_key_aliases(gateway: Gateway) -> None: - with gateway.scenario() as scenario: - model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) - team: Final = scenario.team(models=[model]) - alias: Final = f'=HYPERLINK("http://x.{uuid.uuid4().hex}","x")' - keys: Final = ( - scenario.key(team_id=team, models=[model], key_alias=alias), - scenario.key(team_id=team, models=[model]), - ) - for key in keys: - reply: Final = gateway.chat(model, key=key, text=f"team export {uuid.uuid4().hex}") - assert reply["usage"]["total_tokens"] == 40, reply - digests: Final = tuple(sha256(key.encode()).hexdigest() for key in keys) - eventually( - lambda: read_rows('SELECT api_key FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team,)), - lambda values: len({row["api_key"] for row in values}) == 2, - seconds=70, - ) - response: Final = _export_json(gateway, team_id=team, export_type="daily_with_keys", format="csv") - assert response.status_code == 200, response.text - records: Final = {record["Key ID"]: record for record in csv.DictReader(io.StringIO(response.text))} - assert records[digests[0]]["Key Alias"] == "'" + alias, response.text - assert records[digests[1]]["Key Alias"] == "-", response.text - - -def test_aggregated_route_keeps_the_top_n_key_cap(gateway: Gateway, tmp_path: Path) -> None: - with owned_proxy(gateway, tmp_path, {"USAGE_TOP_API_KEYS_LIMIT": "2"}, workers=2) as candidate: - with candidate.scenario() as scenario: - model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) - team, keys, digests, spend_by_key = _team_with_three_keys(candidate, scenario, model) - response: Final = candidate.request( - "GET", - "/team/daily/activity/aggregated", - params={**_export_range(), "team_ids": team}, - ) - assert response.status_code == 200, response.text - body: Final = object_value(response.json()) - metadata: Final = object_value(body["metadata"]) - assert metadata["api_key_limit"] == 2, response.text - assert metadata["total_api_keys"] == 3, response.text - breakdown: Final = object_value(object_value(body["results"][0])["breakdown"]) - assert len(object_value(breakdown["api_keys"])) == 2, response.text - - -def test_paginated_team_daily_activity_still_lists_the_team(gateway: Gateway) -> None: - with gateway.scenario() as scenario: - model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) - team, keys, digests, spend_by_key = _team_with_three_keys(gateway, scenario, model) - response: Final = gateway.request( - "GET", - "/team/daily/activity", - params={ - "team_ids": team, - "start_date": _export_range()["start_date"], - "end_date": _export_range()["end_date"], - }, - ) - assert response.status_code == 200, response.text - results: Final = object_value(response.json())["results"] - assert isinstance(results, list), response.text - days: Final = tuple( - object_value(day) - for day in results - if team in object_value(object_value(object_value(day)["breakdown"])["entities"]) - ) - assert len(days) == 1, response.text - entity: Final = object_value(object_value(object_value(days[0]["breakdown"])["entities"])[team]) - assert float(object_value(entity["metrics"])["spend"]) == pytest.approx(sum(spend_by_key.values())), ( - response.text - ) - - -def test_openai_sdk_chat_still_lands_one_spend_log(gateway: Gateway) -> None: - with gateway.scenario() as scenario: - model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) - team: Final = scenario.team(models=[model]) - key: Final = scenario.key(team_id=team, models=[model]) - client: Final = openai.OpenAI(base_url=f"{gateway.client.base_url}/v1", api_key=key, max_retries=0) - reply: Final = client.chat.completions.create( - model=model, messages=[{"role": "user", "content": f"sdk {uuid.uuid4().hex}"}], stream=False - ) - rows: Final = eventually( - lambda: read_rows('SELECT request_id FROM "LiteLLM_SpendLogs" WHERE request_id=%s', (reply.id,)), - lambda values: len(values) == 1, - seconds=70, - ) - assert len(rows) == 1 and rows[0]["request_id"] == reply.id, rows - - -def test_export_and_chat_burst_survives_worker_kill(gateway: Gateway, tmp_path: Path) -> None: - with owned_proxy_process(gateway, tmp_path, {"USAGE_TOP_API_KEYS_LIMIT": "2"}, workers=2) as owned: - candidate: Final = owned.gateway - with candidate.scenario() as scenario: - model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) - team, keys, digests, spend_by_key = _team_with_three_keys(candidate, scenario, model) - - workers: Final = eventually( - lambda: tuple(member for member in group_members(owned.process.pid) if member.pid != owned.process.pid), - lambda members: len(members) >= 2, - seconds=30, - ) - assert len(workers) >= 2, workers - - params: Final = { - **_export_range(), - "team_id": team, - "export_type": "daily_with_keys", - "format": "json", - } - - def burst(tag: str) -> tuple[tuple[httpx.Response, ...], tuple[httpx.Response, ...]]: - with ThreadPoolExecutor(max_workers=30) as pool: - futures: Final = tuple( - ( - pool.submit( - candidate.request, - "POST", - "/v1/chat/completions", - { - "model": model, - "messages": [{"role": "user", "content": f"{tag}-{index}-{uuid.uuid4().hex}"}], - }, - key=keys[index % 3], - ) - if index % 2 == 0 - else pool.submit(candidate.request, "GET", "/team/daily/activity/export", params=params) - ) - for index in range(30) - ) - results: Final = tuple(future.result() for future in futures) - return results[0::2], results[1::2] - - chat_a, export_a = burst("bursta") - assert all(response.status_code == 200 for response in chat_a), [r.text for r in chat_a] - assert all(response.status_code == 200 for response in export_a), [r.text for r in export_a] - - victim: Final = workers[0] - os.kill(victim.pid, signal.SIGKILL) - - chat_b, export_b = burst("burstb") - all_chats: Final = chat_a + chat_b - all_exports: Final = export_a + export_b - assert all(response.status_code == 200 for response in all_chats), [ - (r.status_code, r.text) for r in all_chats - ] - for response in all_exports: - assert response.status_code == 200, response.text - returned: Final = {string_value(row["api_key"]) for row in object_value(response.json())["data"]} - assert returned == set(digests), response.text - chat_ids: Final = tuple(string_value(object_value(r.json())["id"]) for r in all_chats) - assert len(set(chat_ids)) == 30 - id_slots: Final = ", ".join("%s" for _ in chat_ids) - rows: Final = eventually( - lambda: read_rows( - f'SELECT request_id, COUNT(*)::int AS n FROM "LiteLLM_SpendLogs" WHERE request_id IN ({id_slots}) GROUP BY request_id', - chat_ids, - ), - lambda values: len(values) == 30, - seconds=70, - ) - assert all(row["n"] == 1 for row in rows), rows diff --git a/tests/proxy_behavior/management/test_team_daily_activity.py b/tests/proxy_behavior/management/test_team_daily_activity.py index f85eb12c403..9bbc8fdde29 100644 --- a/tests/proxy_behavior/management/test_team_daily_activity.py +++ b/tests/proxy_behavior/management/test_team_daily_activity.py @@ -48,9 +48,8 @@ _DATES = "start_date=2024-01-01&end_date=2024-12-31" "/team/daily/activity", "/team/daily/activity/aggregated", "/team/daily/activity/aggregated/search", - "/team/daily/activity/export", ), - ids=("paginated", "aggregated", "search", "export"), + ids=("paginated", "aggregated", "search"), ) @pytest.mark.parametrize( "actor,team,expected_status", @@ -60,15 +59,16 @@ _DATES = "start_date=2024-01-01&end_date=2024-12-31" async def test_team_daily_activity_matrix( actor: Actor, team: str, expected_status: int, endpoint: str, proxy_client, world ): - filter_param = "team_id" if endpoint.endswith("/export") else "team_ids" query = _DATES + ("&search=x" if endpoint.endswith("/search") else "") if team == "alpha": - query += f"&{filter_param}={world.team_alpha_id}" + query += f"&team_ids={world.team_alpha_id}" elif team == "beta": - query += f"&{filter_param}={world.team_beta_id}" + query += f"&team_ids={world.team_beta_id}" resp = await proxy_client.get( f"{endpoint}?{query}", headers={"Authorization": f"Bearer {world.keys[actor].cleartext}"}, ) - assert resp.status_code == expected_status, f"{actor.value} -> {team}: {resp.status_code} {resp.text}" + assert ( + resp.status_code == expected_status + ), f"{actor.value} -> {team}: {resp.status_code} {resp.text}" diff --git a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py index a9604ef1296..1274d69cdf2 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/test_litellm/proxy/management_endpoints/test_common_daily_activity.py @@ -25,7 +25,6 @@ from litellm.proxy.management_endpoints.common_daily_activity import ( get_api_key_metadata, get_daily_activity, get_daily_activity_aggregated, - get_daily_activity_export_rows, global_rollup_reconciled_through, update_metrics, ) @@ -2886,307 +2885,3 @@ async def test_get_api_key_metadata_resolves_cli_session_keys_from_the_key_itsel assert result["cli-session-alice"]["key_alias"] == "cli-session-alice" assert result["cli-session-alice"]["user_email"] == "alice@example.com" assert result["cli-session-alice"]["team_id"] == "team-a" - - -_DAILY_TEAM_SPEND_DDL: Final = """ - CREATE TABLE "LiteLLM_DailyTeamSpend" ( - id TEXT PRIMARY KEY, - team_id TEXT, - date TEXT NOT NULL, - api_key TEXT NOT NULL, - model TEXT, - model_group TEXT, - custom_llm_provider TEXT, - mcp_namespaced_tool_name TEXT, - endpoint TEXT, - prompt_tokens BIGINT DEFAULT 0, - completion_tokens BIGINT DEFAULT 0, - cache_read_input_tokens BIGINT DEFAULT 0, - cache_creation_input_tokens BIGINT DEFAULT 0, - compression_saved_tokens BIGINT DEFAULT 0, - compression_savings_spend DOUBLE PRECISION DEFAULT 0, - prompt_caching_savings_spend DOUBLE PRECISION DEFAULT 0, - gateway_injected_caching_savings_spend DOUBLE PRECISION DEFAULT 0, - autorouter_savings_spend DOUBLE PRECISION DEFAULT 0, - spend DOUBLE PRECISION DEFAULT 0, - ptu_flat_cost DOUBLE PRECISION DEFAULT 0, - api_requests BIGINT DEFAULT 0, - successful_requests BIGINT DEFAULT 0, - failed_requests BIGINT DEFAULT 0, - total_response_time_ms BIGINT DEFAULT 0, - timed_requests BIGINT DEFAULT 0 - ) -""" - - -def _seed_daily_team_spend(conn: psycopg.Connection, rows: Sequence[tuple[object, ...]]) -> None: - with conn.cursor() as cur: - cur.execute(_DAILY_TEAM_SPEND_DDL) - cur.executemany( - """ - INSERT INTO "LiteLLM_DailyTeamSpend" - (id, team_id, date, api_key, model, model_group, custom_llm_provider, - endpoint, prompt_tokens, spend, ptu_flat_cost, api_requests, successful_requests) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - """, - rows, - ) - conn.commit() - - -def _team_spend_row( - row_id: str, - team_id: str, - api_key: str, - spend: float, - *, - date: str = "2026-06-01", - model: str = "gpt-5", - ptu_flat_cost: float = 0.0, -) -> tuple[object, ...]: - return ( - row_id, - team_id, - date, - api_key, - model, - "", - "openai", - "/v1/chat/completions", - 10, - spend, - ptu_flat_cost, - 1, - 1, - ) - - -def _export_prisma(conn: psycopg.Connection, token_rows: Sequence[SimpleNamespace] = ()) -> MagicMock: - mock_prisma = MagicMock() - mock_prisma.db = MagicMock() - mock_prisma.db.query_raw = _psycopg_query_raw(conn, []) - mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=list(token_rows)) - mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[]) - mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[]) - return mock_prisma - - -@pytest.mark.asyncio -async def test_export_keys_returns_every_key_beyond_the_top_n_cap( - _aggregated_postgresql: psycopg.Connection, -): - """The export route exists because the aggregated route caps the per-key arm at - USAGE_TOP_API_KEYS_LIMIT. With more keys than the cap every one of them must - land in the export, while the PTU sentinel stays out of the key view.""" - n_keys: Final = USAGE_TOP_API_KEYS_LIMIT + 7 - _seed_daily_team_spend( - _aggregated_postgresql, - [ - *[_team_spend_row(f"row-{i:03d}", "team-1", f"key-{i:03d}", float(i + 1)) for i in range(n_keys)], - _team_spend_row("row-ptu", "team-1", PTU_SENTINEL_API_KEY, 0.0, ptu_flat_cost=1000.0), - ], - ) - - rows = await get_daily_activity_export_rows( - prisma_client=_export_prisma(_aggregated_postgresql), - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id="team-1", - entity_metadata_field=None, - start_date="2026-06-01", - end_date="2026-06-01", - api_key=None, - exclude_entity_ids=None, - timezone_offset_minutes=None, - export_type="daily_with_keys", - ) - - assert {row.api_key for row in rows} == {f"key-{i:03d}" for i in range(n_keys)} - assert len(rows) == n_keys - assert all(row.team_id == "team-1" for row in rows) - by_key: Final = {row.api_key: row for row in rows} - assert by_key["key-000"].spend == pytest.approx(1.0) - assert sum(row.spend for row in rows) == pytest.approx(n_keys * (n_keys + 1) / 2) - assert all(row.total_tokens == 10 and row.api_requests == 1 for row in rows) - - -@pytest.mark.asyncio -async def test_export_daily_keeps_ptu_sentinel_in_the_team_rollup( - _aggregated_postgresql: psycopg.Connection, -): - """The plain daily export groups by (date, team), so the sentinel's flat cost - must land in the team row exactly like breakdown.entities on the aggregated - route; dropping it would silently under-report team spend.""" - _seed_daily_team_spend( - _aggregated_postgresql, - [ - _team_spend_row("row-1", "team-1", "key-1", 2.0), - _team_spend_row("row-ptu", "team-1", PTU_SENTINEL_API_KEY, 0.0, ptu_flat_cost=0.0), - ], - ) - with _aggregated_postgresql.cursor() as cur: - cur.execute("UPDATE \"LiteLLM_DailyTeamSpend\" SET spend = 1000.0 WHERE id = 'row-ptu'") - _aggregated_postgresql.commit() - - rows = await get_daily_activity_export_rows( - prisma_client=_export_prisma(_aggregated_postgresql), - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id="team-1", - entity_metadata_field={"team-1": {"team_alias": "Alpha"}}, - start_date="2026-06-01", - end_date="2026-06-01", - api_key=None, - exclude_entity_ids=None, - timezone_offset_minutes=None, - export_type="daily", - ) - - assert len(rows) == 1 - assert rows[0].team_id == "team-1" - assert rows[0].team_alias == "Alpha" - assert rows[0].api_key is None - assert rows[0].spend == pytest.approx(1002.0) - - -@pytest.mark.asyncio -async def test_export_users_folds_keys_into_one_row_per_user( - _aggregated_postgresql: psycopg.Connection, -): - """daily_with_users runs the per-key rollup then folds in Python: two keys of - user-1 merge into one row with keys=2 and summed metrics, and the distinct - user keeps its own row.""" - _seed_daily_team_spend( - _aggregated_postgresql, - [ - _team_spend_row("row-1", "team-1", "key-1", 2.0), - _team_spend_row("row-2", "team-1", "key-2", 3.0), - _team_spend_row("row-3", "team-1", "key-3", 5.0), - ], - ) - tokens: Final = tuple( - SimpleNamespace(token=token, key_alias=None, team_id="team-1", user_id=user_id) - for token, user_id in (("key-1", "user-1"), ("key-2", "user-1"), ("key-3", "user-2")) - ) - - rows = await get_daily_activity_export_rows( - prisma_client=_export_prisma(_aggregated_postgresql, tokens), - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id="team-1", - entity_metadata_field=None, - start_date="2026-06-01", - end_date="2026-06-01", - api_key=None, - exclude_entity_ids=None, - timezone_offset_minutes=None, - export_type="daily_with_users", - ) - - assert [(row.user_id, row.keys, row.spend, row.api_requests, row.total_tokens) for row in rows] == [ - ("user-1", 2, 5.0, 2, 20), - ("user-2", 1, 5.0, 1, 10), - ] - - -@pytest.mark.asyncio -async def test_export_models_rolls_up_per_team_and_model( - _aggregated_postgresql: psycopg.Connection, -): - _seed_daily_team_spend( - _aggregated_postgresql, - [ - _team_spend_row("row-1", "team-1", "key-1", 2.0, model="gpt-5"), - _team_spend_row("row-2", "team-1", "key-2", 3.0, model="gpt-5"), - _team_spend_row("row-3", "team-1", "key-1", 5.0, model="claude"), - ], - ) - - rows = await get_daily_activity_export_rows( - prisma_client=_export_prisma(_aggregated_postgresql), - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id="team-1", - entity_metadata_field=None, - start_date="2026-06-01", - end_date="2026-06-01", - api_key=None, - exclude_entity_ids=None, - timezone_offset_minutes=None, - export_type="daily_with_models", - ) - - assert [(row.model, row.spend, row.api_requests) for row in rows] == [ - ("claude", 5.0, 1), - ("gpt-5", 5.0, 2), - ] - - -@pytest.mark.asyncio -async def test_export_daily_reports_ptu_flat_cost_on_the_team_row( - _aggregated_postgresql: psycopg.Connection, ptu_cost_attribution_enabled -): - """The CSV the dashboard hands to finance must match the client-side export, - which shows flat cost columns once any PTU spend exists for the day.""" - from litellm.proxy.management_endpoints.team_endpoints import _team_export_csv - - _seed_daily_team_spend( - _aggregated_postgresql, - [ - _team_spend_row("row-1", "team-1", "key-1", 2.0), - _team_spend_row("row-ptu", "team-1", PTU_SENTINEL_API_KEY, 0.0, ptu_flat_cost=240.0), - ], - ) - - rows = await get_daily_activity_export_rows( - prisma_client=_export_prisma(_aggregated_postgresql), - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id="team-1", - entity_metadata_field=None, - start_date="2026-06-01", - end_date="2026-06-01", - api_key=None, - exclude_entity_ids=None, - timezone_offset_minutes=None, - export_type="daily", - ) - - assert len(rows) == 1 - assert rows[0].flat_cost == pytest.approx(240.0) - header: Final = _team_export_csv("daily", rows).splitlines()[0] - assert "Spend ($),Flat Cost ($),Total Cost ($)" in header - record: Final = _team_export_csv("daily", rows).splitlines()[1].split(",") - spend_index: Final = header.split(",").index("Spend ($)") - assert record[spend_index : spend_index + 3] == ["2.0000", "240.0000", "242.0000"] - - -@pytest.mark.asyncio -async def test_export_csv_omits_flat_cost_columns_when_no_ptu_spend_exists( - _aggregated_postgresql: psycopg.Connection, -): - from litellm.proxy.management_endpoints.team_endpoints import _team_export_csv - - _seed_daily_team_spend( - _aggregated_postgresql, - [_team_spend_row("row-1", "team-1", "key-1", 2.0)], - ) - - rows = await get_daily_activity_export_rows( - prisma_client=_export_prisma(_aggregated_postgresql), - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id="team-1", - entity_metadata_field=None, - start_date="2026-06-01", - end_date="2026-06-01", - api_key=None, - exclude_entity_ids=None, - timezone_offset_minutes=None, - export_type="daily", - ) - - assert rows[0].flat_cost == 0.0 - header: Final = _team_export_csv("daily", rows).splitlines()[0] - assert "Flat Cost" not in header - assert "Total Cost" not in header diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 6d902cb7fec..38241926f8e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -17101,130 +17101,3 @@ def test_list_team_v2_answers_503_no_db_connection_when_the_callers_user_read_hi assert response.status_code == 503, response.text assert response.json() == _DB_OUTAGE_503_BODY - - -def test_team_export_csv_columns_match_the_dashboard_client_layout(): - import csv - import io - - from litellm.proxy.management_endpoints.team_endpoints import _team_export_csv - from litellm.types.proxy.management_endpoints.team_endpoints import TeamDailyActivityExportRow - - row: Final = TeamDailyActivityExportRow( - date="2026-06-01", - team_id="team-1", - team_alias=None, - api_key="key-1", - key_alias="key-alias-1", - user_id="user-1", - user_email="u@example.com", - spend=1.5, - api_requests=2, - successful_requests=2, - failed_requests=0, - total_tokens=30, - prompt_tokens=20, - completion_tokens=10, - cache_read_input_tokens=5, - cache_creation_input_tokens=4, - ) - - records: Final = list(csv.DictReader(io.StringIO(_team_export_csv("daily_with_keys", (row,))))) - - assert records == [ - { - "Date": "2026-06-01", - "Team": "-", - "Team ID": "team-1", - "Key Alias": "key-alias-1", - "Key ID": "key-1", - "User ID": "user-1", - "User Email": "u@example.com", - "Spend ($)": "1.5000", - "Requests": "2", - "Successful Requests": "2", - "Failed Requests": "0", - "Total Tokens": "30", - "Prompt Tokens": "20", - "Completion Tokens": "10", - "Cache Read Input Tokens": "5", - "Cache Creation Input Tokens": "4", - } - ] - - -def test_team_export_csv_omits_key_columns_for_the_plain_daily_scope(): - import csv - import io - - from litellm.proxy.management_endpoints.team_endpoints import _team_export_csv - from litellm.types.proxy.management_endpoints.team_endpoints import TeamDailyActivityExportRow - - row: Final = TeamDailyActivityExportRow( - date="2026-06-01", - team_id="team-1", - team_alias="Alpha", - spend=1.5, - api_requests=2, - successful_requests=2, - failed_requests=0, - total_tokens=30, - prompt_tokens=20, - completion_tokens=10, - cache_read_input_tokens=5, - cache_creation_input_tokens=4, - ) - - text: Final = _team_export_csv("daily", (row,)) - - assert text.splitlines()[0] == ( - "Date,Team,Team ID,Spend ($),Requests,Successful Requests,Failed Requests," - "Total Tokens,Prompt Tokens,Completion Tokens,Cache Read Input Tokens,Cache Creation Input Tokens" - ) - assert list(csv.reader(io.StringIO(text)))[1] == [ - "2026-06-01", - "Alpha", - "team-1", - "1.5000", - "2", - "2", - "0", - "30", - "20", - "10", - "5", - "4", - ] - - -def test_team_export_csv_escapes_formula_aliases_and_keeps_dash_placeholder(): - import csv - import io - - from litellm.proxy.management_endpoints.team_endpoints import _team_export_csv - from litellm.types.proxy.management_endpoints.team_endpoints import TeamDailyActivityExportRow - - row: Final = TeamDailyActivityExportRow( - date="2026-06-01", - team_id="team-1", - team_alias='=HYPERLINK("http://evil.example","x")', - key_alias="@cmd", - user_id=None, - user_email=None, - spend=1.5, - api_requests=2, - successful_requests=2, - failed_requests=0, - total_tokens=30, - prompt_tokens=20, - completion_tokens=10, - cache_read_input_tokens=5, - cache_creation_input_tokens=4, - ) - - record: Final = next(csv.DictReader(io.StringIO(_team_export_csv("daily_with_keys", (row,))))) - - assert record["Team"] == "'=HYPERLINK(\"http://evil.example\",\"x\")" - assert record["Key Alias"] == "'@cmd" - assert record["User ID"] == "-" - assert record["User Email"] == "-" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index d1ebd8b98a0..16dc41c3ba8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -26,7 +26,7 @@ import UserDropdown from "@/components/common_components/UserDropdown"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; import { UsageExportHeader } from "@/components/EntityUsageExport"; import { getApiKeyTruncation, getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason"; -import type { EntityType, ServerExport } from "@/components/EntityUsageExport/types"; +import type { EntityType } from "@/components/EntityUsageExport/types"; import { agentDailyActivityCall, customerDailyActivityCall, @@ -34,7 +34,6 @@ import { tagDailyActivityCall, teamDailyActivityAggregatedCall, teamDailyActivityCall, - teamDailyActivityExportCall, teamDailyActivityKeySearchCall, userDailyActivityCall, } from "@/components/networking"; @@ -686,20 +685,7 @@ const EntityUsage: React.FC = ({ { key: "endpoints", label: "Endpoint Activity", content: }, ]; - const serverExport: ServerExport | undefined = - entityType === "team" && apiKeyTruncation !== undefined && accessToken && startTime && endTime - ? (scope, format) => - teamDailyActivityExportCall({ - accessToken, - startTime, - endTime, - teamIds: entityFilterArg as string[] | null, - exportType: scope, - format, - }) - : undefined; - - const spendFetchState = { coversRange, cancelled, failed, apiKeyTruncation: serverExport ? null : apiKeyTruncation }; + const spendFetchState = { coversRange, cancelled, failed, apiKeyTruncation }; return (
@@ -733,7 +719,6 @@ const EntityUsage: React.FC = ({ filterOptions={getAllTags() || undefined} teams={teams || []} exportBlockedReason={getExportBlockedReason(spendFetchState)} - serverExport={serverExport} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 4c5db0def62..e0171d97423 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -253,15 +253,14 @@ const UsagePage: React.FC = ({ teams, organizations }) => { // Read through the same range stamp as the tiles, so the export is blocked from the first // render of a new range rather than from whenever the fetch effect gets around to running. - const apiKeyTruncation = getApiKeyTruncation( - userSpendData.metadata?.api_key_limit, - userSpendData.metadata?.total_api_keys, - ); const spendFetchState = { coversRange: activeAggregated !== null || paginatedResult.coversRange, cancelled: paginatedResult.cancelled, failed: paginatedResult.failed, - apiKeyTruncation, + apiKeyTruncation: getApiKeyTruncation( + userSpendData.metadata?.api_key_limit, + userSpendData.metadata?.total_api_keys, + ), }; const exportBlockedReason = getExportBlockedReason(spendFetchState); @@ -878,7 +877,7 @@ const UsagePage: React.FC = ({ teams, organizations }) => { diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx index 6f74d241166..cb04323e4c9 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx @@ -1,3 +1,14 @@ +/** + * Tests for EntityUsageExportModal component + * + * Validates core export functionality: + * - Renders modal with correct default state (CSV format, daily scope) + * - User can select export type (daily vs daily_with_models) + * - User can switch format (CSV vs JSON) + * - Export button triggers data generation with correct parameters + * - Modal closes after successful export + */ + import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen } from "@testing-library/react"; import { renderWithProviders } from "../../../tests/test-utils"; @@ -9,7 +20,6 @@ vi.mock("./utils", () => { return { handleExportCSV: vi.fn(), handleExportJSON: vi.fn(), - handleServerExport: vi.fn(async () => undefined), generateExportData: vi.fn(() => [{ Date: "2025-10-01" }]), generateMetadata: vi.fn(() => ({ meta: true })), }; @@ -104,23 +114,4 @@ describe("EntityUsageExportModal", () => { // Modal closes after export expect(baseProps.onClose).toHaveBeenCalled(); }); - - it("routes the export through the server export when one is provided, so truncated key lists still export", async () => { - /** - * When the spend fetch was capped at the top-N keys, the caller supplies a - * serverExport that hits the uncapped export route. The modal must defer to - * it instead of generating a CSV from the truncated on-screen data. - */ - const user = userEvent.setup(); - const { handleExportCSV, handleServerExport } = await import("./utils"); - const serverExport = vi.fn(async () => new Blob(["csv"])); - - renderWithProviders(); - - await user.click(screen.getByRole("button", { name: /Export CSV/i })); - - expect(handleServerExport).toHaveBeenCalledWith(serverExport, "daily", "team", "csv"); - expect(handleExportCSV).not.toHaveBeenCalled(); - expect(baseProps.onClose).toHaveBeenCalled(); - }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx index 5e177a3efe6..ec688e9fc3d 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx @@ -10,7 +10,7 @@ import ExportFormatSelector from "./ExportFormatSelector"; import ExportSummary from "./ExportSummary"; import ExportTypeSelector from "./ExportTypeSelector"; import type { EntityUsageExportModalProps, ExportFormat, ExportScope } from "./types"; -import { handleExportCSV, handleExportJSON, handleServerExport } from "./utils"; +import { handleExportCSV, handleExportJSON } from "./utils"; const EntityUsageExportModal: React.FC = ({ isOpen, @@ -20,7 +20,6 @@ const EntityUsageExportModal: React.FC = ({ dateRange, selectedFilters, customTitle, - serverExport, }) => { const [exportFormat, setExportFormat] = useState("csv"); const [exportScope, setExportScope] = useState("daily"); @@ -36,10 +35,7 @@ const EntityUsageExportModal: React.FC = ({ const formatToUse = format || exportFormat; setIsExporting(true); try { - if (serverExport) { - await handleServerExport(serverExport, exportScope, entityType, formatToUse); - toast.success(`${entityLabel} usage data exported successfully as ${formatToUse.toUpperCase()}`); - } else if (formatToUse === "csv") { + if (formatToUse === "csv") { handleExportCSV(spendData, exportScope, entityLabel, entityType, teamAliasMap); toast.success(`${entityLabel} usage data exported successfully as CSV`); } else { diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx index 10825e18f3d..388a211d9bb 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/UsageExportHeader.tsx @@ -16,7 +16,7 @@ import { useComboboxAnchor, } from "@/components/ui/combobox"; import EntityUsageExportModal from "./EntityUsageExportModal"; -import type { EntitySpendData, EntityType, ServerExport } from "./types"; +import type { EntitySpendData, EntityType } from "./types"; import type { Team } from "@/components/key_team_helpers/key_list"; interface UsageExportHeaderProps { @@ -35,7 +35,6 @@ interface UsageExportHeaderProps { compactLayout?: boolean; teams?: Team[]; exportBlockedReason?: string; - serverExport?: ServerExport; } const UsageExportHeader: React.FC = ({ @@ -53,7 +52,6 @@ const UsageExportHeader: React.FC = ({ compactLayout = false, teams = [], exportBlockedReason, - serverExport, }) => { const anchor = useComboboxAnchor(); const [isExportModalOpen, setIsExportModalOpen] = useState(false); @@ -144,7 +142,6 @@ const UsageExportHeader: React.FC = ({ selectedFilters={selectedFilters} customTitle={customTitle} teams={teams} - serverExport={serverExport} /> ); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts index 3d84ef4703a..8491b31f5f9 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts @@ -39,10 +39,6 @@ describe("getExportBlockedReason", () => { expect(reason).toMatch(/100 highest-spend keys of 3000/); expect(reason).toMatch(/USAGE_TOP_API_KEYS_LIMIT/); }); - - it("does not block on truncation when a server export will cover every key", () => { - expect(getExportBlockedReason(state({ apiKeyTruncation: null }))).toBeUndefined(); - }); }); describe("getApiKeyTruncation", () => { diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts index 32601680bad..6c5a5f83231 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts @@ -7,7 +7,7 @@ export interface UsageFetchState { coversRange: boolean; cancelled: boolean; failed: boolean; - apiKeyTruncation?: ApiKeyTruncation | null; + apiKeyTruncation: ApiKeyTruncation | undefined; } export const getApiKeyTruncation = (apiKeyLimit: unknown, totalApiKeys: unknown): ApiKeyTruncation | undefined => { @@ -25,7 +25,7 @@ export const getExportBlockedReason = ({ if (cancelled) return "Loading was stopped before the whole range arrived, so an export would under-report. Reload the page to load it all."; if (!coversRange) return "Spend data is still loading, so an export would under-report. Wait for it to finish."; - if (apiKeyTruncation) + if (apiKeyTruncation !== undefined) return `Only the ${apiKeyTruncation.limit} highest-spend keys of ${apiKeyTruncation.total} were loaded, so a per-team export would under-report. Raise USAGE_TOP_API_KEYS_LIMIT on the proxy to load more keys.`; return undefined; }; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts index 9cca4a2ab2b..15f193ecc3f 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -17,8 +17,6 @@ export interface EntitySpendData { }; } -export type ServerExport = (exportScope: ExportScope, format: ExportFormat) => Promise; - export interface EntityUsageExportModalProps { isOpen: boolean; onClose: () => void; @@ -28,7 +26,6 @@ export interface EntityUsageExportModalProps { selectedFilters: string[]; customTitle?: string; teams?: Team[]; - serverExport?: ServerExport; } export interface ExportMetadata { diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts index 4273a54e07d..3f9cf58ec20 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -14,7 +14,6 @@ import { getEntityBreakdown, handleExportCSV, handleExportJSON, - handleServerExport, resolveEntities, } from "./utils"; @@ -3006,40 +3005,4 @@ describe("EntityUsageExport utils", () => { ]); }); }); - - describe("handleServerExport", () => { - beforeEach(() => { - document.body.innerHTML = ""; - window.URL.createObjectURL = vi.fn(() => "blob:mock-url"); - window.URL.revokeObjectURL = vi.fn(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("passes the chosen scope and format to the server export and downloads the returned blob", async () => { - const serverBlob = new Blob(["payload"], { type: "text/csv" }); - const serverExport = vi.fn(async () => serverBlob); - const createObjectURLSpy = vi.spyOn(window.URL, "createObjectURL"); - const appendChildSpy = vi.spyOn(document.body, "appendChild"); - - await handleServerExport(serverExport, "daily_with_keys", "team", "csv"); - - expect(serverExport).toHaveBeenCalledWith("daily_with_keys", "csv"); - expect(createObjectURLSpy).toHaveBeenCalledWith(serverBlob); - const attached = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement; - const today = new Date().toISOString().split("T")[0]; - expect(attached.download).toBe(`team_usage_daily_with_keys_${today}.csv`); - }); - - it("lets a server failure propagate so the modal can toast it instead of downloading nothing", async () => { - const serverExport = vi.fn(async () => { - throw new Error("upstream 500"); - }); - - await expect(handleServerExport(serverExport, "daily", "team", "json")).rejects.toThrow("upstream 500"); - expect(document.body.querySelector("a")).toBeNull(); - }); - }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts index 861ea585142..95ce584cc89 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -2,27 +2,21 @@ import { formatNumberWithCommas } from "@/utils/dataUtils"; import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; import Papa from "papaparse"; import { keyActivityLabel } from "@/components/UsagePage/keyActivityLabel"; -import type { - EntityBreakdown, - EntitySpendData, - EntityType, - ExportFormat, - ExportMetadata, - ExportScope, - ServerExport, -} from "./types"; +import type { EntityBreakdown, EntitySpendData, EntityType, ExportMetadata, ExportScope } from "./types"; const resolveEntityDisplay = ( entity: string, teamAliasMap: Record, entityMetadata?: Record, -): { id: string; alias: string } => { - const alias = - [teamAliasMap[entity], entityMetadata?.team_alias, entityMetadata?.user_email, entityMetadata?.user_alias].find( - Boolean, - ) ?? entity; - return { id: entity, alias }; -}; +): { id: string; alias: string } => ({ + id: entity, + alias: + teamAliasMap[entity] || + entityMetadata?.team_alias || + entityMetadata?.user_email || + entityMetadata?.user_alias || + entity, +}); // Mirrors backend SpendMetrics fields (litellm/types/activity_tracking.py). // If the backend adds a field, add it here too. @@ -381,7 +375,7 @@ export const generateDailyWithModelsData = ( const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, dailyEntityMetadata[entity]); Object.entries(models).forEach(([model, metrics]: [string, any]) => { - const row = { + dailyModelBreakdown.push({ Date: day.date, [entityLabel]: alias, [`${entityLabel} ID`]: id, @@ -395,8 +389,7 @@ export const generateDailyWithModelsData = ( "Completion Tokens": metrics.completionTokens, "Cache Read Input Tokens": metrics.cacheReadInputTokens, "Cache Creation Input Tokens": metrics.cacheCreationInputTokens, - }; - dailyModelBreakdown.push(row); + }); }); }); }); @@ -456,28 +449,6 @@ export const generateMetadata = ( }; }; -export const downloadBlob = (blob: Blob, fileName: string): void => { - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); -}; - -export const handleServerExport = async ( - serverExport: ServerExport, - exportScope: ExportScope, - entityType: EntityType, - format: ExportFormat, -): Promise => { - const blob = await serverExport(exportScope, format); - const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.${format}`; - downloadBlob(blob, fileName); -}; - export const handleExportCSV = ( spendData: EntitySpendData, exportScope: ExportScope, @@ -488,8 +459,15 @@ export const handleExportCSV = ( const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap); const csv = Papa.unparse(data); const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.csv`; - downloadBlob(blob, fileName); + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); }; export const handleExportJSON = ( @@ -509,6 +487,13 @@ export const handleExportJSON = ( }; const jsonString = JSON.stringify(exportObject, null, 2); const blob = new Blob([jsonString], { type: "application/json" }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.json`; - downloadBlob(blob, fileName); + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); }; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index e1271b9151f..aeb41687788 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -111,7 +111,6 @@ import type { CoordinationRedisTestResponse, } from "@/app/(dashboard)/caching/_components/coordination_redis_settings/types"; import { MCP_TOOLS_PREVIEW_FORBIDDEN_MESSAGE } from "./mcp_tools/constants"; -import type { ExportFormat, ExportScope } from "./EntityUsageExport/types"; import type { ComplexityRouterConfigPayload } from "./add_model/build_complexity_router_config"; import type { AutoRouterPresetsResponse } from "@/lib/autorouter_presets"; import type { VectorStoreIndex } from "@/app/(dashboard)/vector-stores/_components/IndexesTab"; @@ -1468,36 +1467,6 @@ export const teamDailyActivityAggregatedCall = async ( } }; -export const teamDailyActivityExportCall = async ({ - accessToken, - startTime, - endTime, - teamIds, - exportType, - format, -}: { - accessToken: string; - startTime: Date; - endTime: Date; - teamIds: string[] | null; - exportType: ExportScope; - format: ExportFormat; -}): Promise => { - return apiClient.get(`/team/daily/activity/export`, { - accessToken, - responseType: "blob", - query: { - start_date: formatDate(startTime), - end_date: formatDate(endTime), - timezone: new Date().getTimezoneOffset().toString(), - export_type: exportType, - format, - team_id: teamIds && teamIds.length > 0 ? teamIds.join(",") : undefined, - exclude_team_ids: "litellm-dashboard", - }, - }); -}; - export const teamDailyActivityKeySearchCall = async ( accessToken: string, startTime: Date, diff --git a/ui/litellm-dashboard/src/lib/http/client.ts b/ui/litellm-dashboard/src/lib/http/client.ts index 974c07736e9..c78b2b10011 100644 --- a/ui/litellm-dashboard/src/lib/http/client.ts +++ b/ui/litellm-dashboard/src/lib/http/client.ts @@ -22,8 +22,6 @@ export interface RequestOptions { body?: unknown; /** Sent verbatim (FormData, Blob, pre-stringified text); disables JSON handling. */ rawBody?: BodyInit; - /** Response body handling. Defaults to JSON parsing; use this for downloads. */ - responseType?: "json" | "blob" | "text"; query?: QueryParams; headers?: Record; signal?: AbortSignal; @@ -140,7 +138,7 @@ export function createApiClient(config: ApiClientConfig): ApiClient { const doFetch: typeof fetch = (input, init) => (fetchImpl ?? fetch)(input, init); async function request(method: HttpMethod, path: string, options: RequestOptions = {}): Promise { - const { accessToken, body, rawBody, query, headers: extraHeaders, signal, credentials, responseType } = options; + const { accessToken, body, rawBody, query, headers: extraHeaders, signal, credentials } = options; const url = appendQuery(`${getBaseUrl()}${path}`, query); @@ -179,12 +177,6 @@ export function createApiClient(config: ApiClientConfig): ApiClient { throw new ApiError(message, response.status, errorBody); } - if (responseType === "blob") { - return (await response.blob()) as T; - } - if (responseType === "text") { - return (await response.text()) as T; - } const text = await response.text(); return (text ? JSON.parse(text) : undefined) as T; } diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 5924abfb0c6..16325970ed1 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -15694,32 +15694,6 @@ export interface paths { patch?: never; trace?: never; }; - "/team/daily/activity/export": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * Get Team Daily Activity Export - * @description Server-side Team Usage export, not subject to USAGE_TOP_API_KEYS_LIMIT. - * - * Same scoping as /team/daily/activity/aggregated, answered by one unbounded - * rollup query, returned as CSV or JSON. For daily_with_keys, - * daily_with_users and daily_with_models the PTU sentinel flat-cost rows are - * excluded, so metadata totals under those export types cover request spend - * only; the plain daily export includes them. - */ - get: operations["get_team_daily_activity_export_team_daily_activity_export_get"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/team/delete": { parameters: { query?: never; @@ -31622,7 +31596,7 @@ export interface components { * @description Enum for key management routes * @enum {string} */ - KeyManagementRoutes: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/{key_id}/regenerate" | "/key/block" | "/key/unblock" | "/key/bulk_update" | "/team/key/bulk_update" | "/key/{key_id}/reset_spend" | "/key/access_group_assignment" | "/auto_router/manage" | "/key/info" | "/key/health" | "/key/list" | "/key/aliases" | "/team/daily/activity" | "/team/daily/activity/aggregated" | "/team/daily/activity/export" | "/team/daily/activity/aggregated/search" | "/spend/logs" | "/spend/logs/v2"; + KeyManagementRoutes: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/{key_id}/regenerate" | "/key/block" | "/key/unblock" | "/key/bulk_update" | "/team/key/bulk_update" | "/key/{key_id}/reset_spend" | "/key/access_group_assignment" | "/auto_router/manage" | "/key/info" | "/key/health" | "/key/list" | "/key/aliases" | "/team/daily/activity" | "/team/daily/activity/aggregated" | "/team/daily/activity/aggregated/search" | "/spend/logs" | "/spend/logs/v2"; /** * KeyManagementSystem * @enum {string} @@ -43089,87 +43063,6 @@ export interface components { /** Team Id */ team_id: string; }; - /** TeamDailyActivityExportMetadata */ - TeamDailyActivityExportMetadata: { - /** End Date */ - end_date: string; - /** Export Date */ - export_date: string; - /** - * Export Type - * @enum {string} - */ - export_type: "daily" | "daily_with_keys" | "daily_with_users" | "daily_with_models"; - /** Start Date */ - start_date: string; - /** Team Ids */ - team_ids: string[] | null; - /** Total Api Requests */ - total_api_requests: number; - /** Total Failed Requests */ - total_failed_requests: number; - /** - * Total Flat Cost - * @default 0 - */ - total_flat_cost: number; - /** Total Spend */ - total_spend: number; - /** Total Successful Requests */ - total_successful_requests: number; - /** Total Tokens */ - total_tokens: number; - }; - /** TeamDailyActivityExportResponse */ - TeamDailyActivityExportResponse: { - /** Data */ - data: components["schemas"]["TeamDailyActivityExportRow"][]; - metadata: components["schemas"]["TeamDailyActivityExportMetadata"]; - }; - /** TeamDailyActivityExportRow */ - TeamDailyActivityExportRow: { - /** Api Key */ - api_key?: string | null; - /** Api Requests */ - api_requests: number; - /** Cache Creation Input Tokens */ - cache_creation_input_tokens: number; - /** Cache Read Input Tokens */ - cache_read_input_tokens: number; - /** Completion Tokens */ - completion_tokens: number; - /** Date */ - date: string; - /** Failed Requests */ - failed_requests: number; - /** - * Flat Cost - * @default 0 - */ - flat_cost: number; - /** Key Alias */ - key_alias?: string | null; - /** Keys */ - keys?: number | null; - /** Model */ - model?: string | null; - /** Prompt Tokens */ - prompt_tokens: number; - /** Spend */ - spend: number; - /** Successful Requests */ - successful_requests: number; - /** Team Alias */ - team_alias?: string | null; - /** Team Id */ - team_id: string; - /** Total Tokens */ - total_tokens: number; - /** User Email */ - user_email?: string | null; - /** User Id */ - user_id?: string | null; - }; /** * TeamListItem * @description A team item in the paginated list response, enriched with computed fields. @@ -67096,44 +66989,6 @@ export interface operations { }; }; }; - get_team_daily_activity_export_team_daily_activity_export_get: { - parameters: { - query?: { - start_date?: string | null; - end_date?: string | null; - export_type?: "daily" | "daily_with_keys" | "daily_with_users" | "daily_with_models"; - format?: "csv" | "json"; - team_id?: string | null; - exclude_team_ids?: string | null; - timezone?: number | null; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["TeamDailyActivityExportResponse"]; - "text/csv": unknown; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; delete_team_team_delete_post: { parameters: { query?: never;