From 77eccaca78d25820c54c6b2703711efe3f95224f Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:40:36 -0500 Subject: [PATCH] feat(proxy): server-side Team Usage export beyond the top-N key cap (#42996) * feat(proxy): add uncapped server-side team usage export route GET /team/daily/activity/export answers the same scoping as /team/daily/activity/aggregated with one unbounded rollup query, so keys past USAGE_TOP_API_KEYS_LIMIT are included. Supports daily, daily_with_keys, daily_with_users and daily_with_models export types as CSV (default) or JSON. The PTU flat-cost sentinel stays in the plain daily rollup and is excluded from the keyed and per-model exports Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(ui): export team usage server-side when the key list was truncated When the aggregated spend response reports api_key truncation, EntityUsage passes a serverExport into the export modal that downloads CSV or JSON from GET /team/daily/activity/export instead of building the file from the truncated on-screen data. apiClient gains a responseType option so the download can arrive as a Blob, and truncation no longer blocks the export button Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): cover team usage export types, sentinel handling and scope Unit tests pin the uncapped key rollup past USAGE_TOP_API_KEYS_LIMIT, PTU sentinel inclusion in the daily rollup and exclusion elsewhere, the per-user fold, and the CSV column layout. Integration tests exercise the route against a live proxy, including member scope denial Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(proxy): tidy team usage export route Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): use membership test for export type branch (PLR1714) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(ui): format exportBlockedReason test with prettier Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): satisfy type-discipline gate in team usage export Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): pass export rows as a sequence to the response model Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy-behavior): cover team usage export in the daily activity scope matrix Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(proxy): carry PTU flat cost and escape formulas in team usage export Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep the truncation export block on surfaces without a server export Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore(ui): drop redundant comments in team export call and modal test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): audit cells for team usage export Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): tighten team usage export audit cells Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): type the export params tuple and fold user keys in one pass Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(ui): bring entity usage export helpers under eslint budgets Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(ui): prettier-format UsagePageView after merge Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 4 + .../common_daily_activity.py | 293 +++++++++- .../management_endpoints/team_endpoints.py | 183 +++++- .../management_endpoints/team_endpoints.py | 45 ++ .../endpointaudit/coverage_allowlist.txt | 1 + .../spend/test_team_daily_activity_export.py | 522 ++++++++++++++++++ .../management/test_team_daily_activity.py | 12 +- .../test_common_daily_activity.py | 305 ++++++++++ .../test_team_endpoints.py | 127 +++++ .../components/EntityUsage/EntityUsage.tsx | 19 +- .../_components/components/UsagePageView.tsx | 11 +- .../EntityUsageExportModal.test.tsx | 31 +- .../EntityUsageExportModal.tsx | 8 +- .../EntityUsageExport/UsageExportHeader.tsx | 5 +- .../exportBlockedReason.test.ts | 4 + .../EntityUsageExport/exportBlockedReason.ts | 4 +- .../src/components/EntityUsageExport/types.ts | 3 + .../EntityUsageExport/utils.test.ts | 37 ++ .../src/components/EntityUsageExport/utils.ts | 71 ++- .../src/components/networking.tsx | 31 ++ ui/litellm-dashboard/src/lib/http/client.ts | 10 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 147 ++++- 22 files changed, 1812 insertions(+), 61 deletions(-) create mode 100644 tests/integration/spend/test_team_daily_activity_export.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 73e3e0e6ee0..b6de36f8423 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -307,6 +307,7 @@ 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 @@ -719,6 +720,7 @@ 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 @@ -890,6 +892,7 @@ 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", @@ -990,6 +993,7 @@ 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 bf8e7bc15fb..1d797206ead 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -1,4 +1,6 @@ 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 @@ -35,6 +37,10 @@ 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 ( @@ -198,7 +204,7 @@ class _AggregatedQueryKwargs(TypedDict): include_current_utc_day: ReadOnly[bool] -_SqlQuery = tuple[str, list[str]] +_SqlQuery = tuple[str, Sequence[str]] async def _query_raw_optional( @@ -974,6 +980,291 @@ 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 493c83c730a..8a6cd1218ee 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -11,6 +11,8 @@ All /team management endpoints import asyncio import copy +import csv +import io import json import math import traceback @@ -33,7 +35,8 @@ from typing import ( ) import fastapi -from fastapi import APIRouter, Depends, Header, HTTPException, Request, status +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response, status +from fastapi.responses import JSONResponse from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError from typing_extensions import ReadOnly, TypedDict, assert_never @@ -126,6 +129,7 @@ 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, @@ -206,6 +210,11 @@ from litellm.types.proxy.management_endpoints.team_endpoints import ( BulkUpdateTeamMemberPermissionsRequest, BulkUpdateTeamMemberPermissionsResponse, GetTeamMemberPermissionsResponse, + TeamDailyActivityExportFormat, + TeamDailyActivityExportMetadata, + TeamDailyActivityExportResponse, + TeamDailyActivityExportRow, + TeamDailyActivityExportType, TeamIdSearchFilter, TeamIdSearchMatch, TeamKeyActivitySearchWhere, @@ -6809,6 +6818,178 @@ 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 aac2703e918..9b87d114a81 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -277,3 +277,48 @@ 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 b0f28a9c740..7aacf7ceab9 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -28,6 +28,7 @@ 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 new file mode 100644 index 00000000000..b35d3fe0c8a --- /dev/null +++ b/tests/integration/spend/test_team_daily_activity_export.py @@ -0,0 +1,522 @@ +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 9bbc8fdde29..f85eb12c403 100644 --- a/tests/proxy_behavior/management/test_team_daily_activity.py +++ b/tests/proxy_behavior/management/test_team_daily_activity.py @@ -48,8 +48,9 @@ _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"), + ids=("paginated", "aggregated", "search", "export"), ) @pytest.mark.parametrize( "actor,team,expected_status", @@ -59,16 +60,15 @@ _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"&team_ids={world.team_alpha_id}" + query += f"&{filter_param}={world.team_alpha_id}" elif team == "beta": - query += f"&team_ids={world.team_beta_id}" + query += f"&{filter_param}={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 baaf3f4ba2f..cecdb937c40 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,6 +25,7 @@ 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, ) @@ -2868,3 +2869,307 @@ def test_spend_logs_window_is_none_when_no_date_parses(): from litellm.proxy.management_endpoints.common_daily_activity import _spend_logs_window assert _spend_logs_window({"garbage", ""}) is None + + +_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 38241926f8e..6d902cb7fec 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -17101,3 +17101,130 @@ 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 16dc41c3ba8..d1ebd8b98a0 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 } from "@/components/EntityUsageExport/types"; +import type { EntityType, ServerExport } from "@/components/EntityUsageExport/types"; import { agentDailyActivityCall, customerDailyActivityCall, @@ -34,6 +34,7 @@ import { tagDailyActivityCall, teamDailyActivityAggregatedCall, teamDailyActivityCall, + teamDailyActivityExportCall, teamDailyActivityKeySearchCall, userDailyActivityCall, } from "@/components/networking"; @@ -685,7 +686,20 @@ const EntityUsage: React.FC = ({ { key: "endpoints", label: "Endpoint Activity", content: }, ]; - const spendFetchState = { coversRange, cancelled, failed, apiKeyTruncation }; + 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 }; return (
@@ -719,6 +733,7 @@ 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 e0171d97423..4c5db0def62 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,14 +253,15 @@ 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: getApiKeyTruncation( - userSpendData.metadata?.api_key_limit, - userSpendData.metadata?.total_api_keys, - ), + apiKeyTruncation, }; const exportBlockedReason = getExportBlockedReason(spendFetchState); @@ -877,7 +878,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 cb04323e4c9..6f74d241166 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx @@ -1,14 +1,3 @@ -/** - * 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"; @@ -20,6 +9,7 @@ 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 })), }; @@ -114,4 +104,23 @@ 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 ec688e9fc3d..5e177a3efe6 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 } from "./utils"; +import { handleExportCSV, handleExportJSON, handleServerExport } from "./utils"; const EntityUsageExportModal: React.FC = ({ isOpen, @@ -20,6 +20,7 @@ const EntityUsageExportModal: React.FC = ({ dateRange, selectedFilters, customTitle, + serverExport, }) => { const [exportFormat, setExportFormat] = useState("csv"); const [exportScope, setExportScope] = useState("daily"); @@ -35,7 +36,10 @@ const EntityUsageExportModal: React.FC = ({ const formatToUse = format || exportFormat; setIsExporting(true); try { - if (formatToUse === "csv") { + if (serverExport) { + await handleServerExport(serverExport, exportScope, entityType, formatToUse); + toast.success(`${entityLabel} usage data exported successfully as ${formatToUse.toUpperCase()}`); + } else 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 388a211d9bb..10825e18f3d 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 } from "./types"; +import type { EntitySpendData, EntityType, ServerExport } from "./types"; import type { Team } from "@/components/key_team_helpers/key_list"; interface UsageExportHeaderProps { @@ -35,6 +35,7 @@ interface UsageExportHeaderProps { compactLayout?: boolean; teams?: Team[]; exportBlockedReason?: string; + serverExport?: ServerExport; } const UsageExportHeader: React.FC = ({ @@ -52,6 +53,7 @@ const UsageExportHeader: React.FC = ({ compactLayout = false, teams = [], exportBlockedReason, + serverExport, }) => { const anchor = useComboboxAnchor(); const [isExportModalOpen, setIsExportModalOpen] = useState(false); @@ -142,6 +144,7 @@ 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 8491b31f5f9..3d84ef4703a 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts @@ -39,6 +39,10 @@ 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 6c5a5f83231..32601680bad 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 | undefined; + apiKeyTruncation?: ApiKeyTruncation | null; } 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 !== undefined) + if (apiKeyTruncation) 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 15f193ecc3f..9cca4a2ab2b 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -17,6 +17,8 @@ export interface EntitySpendData { }; } +export type ServerExport = (exportScope: ExportScope, format: ExportFormat) => Promise; + export interface EntityUsageExportModalProps { isOpen: boolean; onClose: () => void; @@ -26,6 +28,7 @@ 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 3f9cf58ec20..4273a54e07d 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -14,6 +14,7 @@ import { getEntityBreakdown, handleExportCSV, handleExportJSON, + handleServerExport, resolveEntities, } from "./utils"; @@ -3005,4 +3006,40 @@ 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 95ce584cc89..861ea585142 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -2,21 +2,27 @@ 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, ExportMetadata, ExportScope } from "./types"; +import type { + EntityBreakdown, + EntitySpendData, + EntityType, + ExportFormat, + ExportMetadata, + ExportScope, + ServerExport, +} from "./types"; const resolveEntityDisplay = ( entity: string, teamAliasMap: Record, entityMetadata?: Record, -): { id: string; alias: string } => ({ - id: entity, - alias: - teamAliasMap[entity] || - entityMetadata?.team_alias || - entityMetadata?.user_email || - entityMetadata?.user_alias || - entity, -}); +): { id: string; alias: string } => { + const alias = + [teamAliasMap[entity], entityMetadata?.team_alias, entityMetadata?.user_email, entityMetadata?.user_alias].find( + Boolean, + ) ?? entity; + return { id: entity, alias }; +}; // Mirrors backend SpendMetrics fields (litellm/types/activity_tracking.py). // If the backend adds a field, add it here too. @@ -375,7 +381,7 @@ export const generateDailyWithModelsData = ( const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, dailyEntityMetadata[entity]); Object.entries(models).forEach(([model, metrics]: [string, any]) => { - dailyModelBreakdown.push({ + const row = { Date: day.date, [entityLabel]: alias, [`${entityLabel} ID`]: id, @@ -389,7 +395,8 @@ export const generateDailyWithModelsData = ( "Completion Tokens": metrics.completionTokens, "Cache Read Input Tokens": metrics.cacheReadInputTokens, "Cache Creation Input Tokens": metrics.cacheCreationInputTokens, - }); + }; + dailyModelBreakdown.push(row); }); }); }); @@ -449,6 +456,28 @@ 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, @@ -459,15 +488,8 @@ 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`; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); + downloadBlob(blob, fileName); }; export const handleExportJSON = ( @@ -487,13 +509,6 @@ 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`; - a.download = fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); + downloadBlob(blob, fileName); }; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index edaf56a8a16..19af2e79bdd 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -111,6 +111,7 @@ 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"; @@ -1467,6 +1468,36 @@ 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 c78b2b10011..974c07736e9 100644 --- a/ui/litellm-dashboard/src/lib/http/client.ts +++ b/ui/litellm-dashboard/src/lib/http/client.ts @@ -22,6 +22,8 @@ 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; @@ -138,7 +140,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 } = options; + const { accessToken, body, rawBody, query, headers: extraHeaders, signal, credentials, responseType } = options; const url = appendQuery(`${getBaseUrl()}${path}`, query); @@ -177,6 +179,12 @@ 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 a398b2db93c..5d0bb56936a 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -15666,6 +15666,32 @@ 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; @@ -31399,7 +31425,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/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/export" | "/team/daily/activity/aggregated/search" | "/spend/logs" | "/spend/logs/v2"; /** * KeyManagementSystem * @enum {string} @@ -42812,6 +42838,87 @@ 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. @@ -66682,6 +66789,44 @@ 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;