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 <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-09-24 15:40:36 -05:00 • committed by GitHub
parent e64e635185
commit 77eccaca78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1812 additions and 61 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<EntityUsageProps> = ({
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
];
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 (
<div style={{ width: "100%" }} className="relative">
@ -719,6 +733,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
filterOptions={getAllTags() || undefined}
teams={teams || []}
exportBlockedReason={getExportBlockedReason(spendFetchState)}
serverExport={serverExport}
/>
<Tabs defaultValue={tabs[0].key}>
<TabsList className="mt-1">

View file

@ -253,14 +253,15 @@ const UsagePage: React.FC<UsagePageProps> = ({ 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<UsagePageProps> = ({ teams, organizations }) => {
<TabsContent value="keys" keepMounted>
<KeyActivityPanel
keyMetrics={keyMetrics}
apiKeyTruncation={spendFetchState.apiKeyTruncation}
apiKeyTruncation={apiKeyTruncation}
searchKeys={searchKeys}
/>
</TabsContent>

View file

@ -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(<EntityUsageExportModal {...baseProps} entityType="team" serverExport={serverExport} />);
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();
});
});

View file

@ -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<EntityUsageExportModalProps> = ({
isOpen,
@ -20,6 +20,7 @@ const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
dateRange,
selectedFilters,
customTitle,
serverExport,
}) => {
const [exportFormat, setExportFormat] = useState<ExportFormat>("csv");
const [exportScope, setExportScope] = useState<ExportScope>("daily");
@ -35,7 +36,10 @@ const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
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 {

View file

@ -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<UsageExportHeaderProps> = ({
@ -52,6 +53,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
compactLayout = false,
teams = [],
exportBlockedReason,
serverExport,
}) => {
const anchor = useComboboxAnchor();
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
@ -142,6 +144,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
selectedFilters={selectedFilters}
customTitle={customTitle}
teams={teams}
serverExport={serverExport}
/>
</>
);

View file

@ -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", () => {

View file

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

View file

@ -17,6 +17,8 @@ export interface EntitySpendData {
};
}
export type ServerExport = (exportScope: ExportScope, format: ExportFormat) => Promise<Blob>;
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 {

View file

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

View file

@ -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<string, string>,
entityMetadata?: Record<string, any>,
): { 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<void> => {
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);
};

View file

@ -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<Blob> => {
return apiClient.get<Blob>(`/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,

View file

@ -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<string, string>;
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<T = any>(method: HttpMethod, path: string, options: RequestOptions = {}): Promise<T> {
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;
}

View file

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