mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
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:
parent
e64e635185
commit
77eccaca78
22 changed files with 1812 additions and 61 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
522
tests/integration/spend/test_team_daily_activity_export.py
Normal file
522
tests/integration/spend/test_team_daily_activity_export.py
Normal 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
|
||||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"] == "-"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
147
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
147
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue