diff --git a/litellm/constants.py b/litellm/constants.py index 23962400f2a..af4d1268c03 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -2162,6 +2162,17 @@ MCP_SPEND_LOG_MODEL_PREFIX: Final[str] = "MCP: " PTU_SENTINEL_API_KEY: Final[str] = "__ptu_flat_cost__" PTU_ROLLUP_JOB_ID: Final[str] = "ptu_flat_cost_rollup_job" PTU_ROLLUP_LOCK_TTL_SECONDS: Final[int] = 900 +USAGE_TOP_API_KEYS_DEFAULT: Final[int] = 100 +USAGE_TOP_API_KEYS_MAX: Final[int] = 1000 +USAGE_KEY_PAGE_DEFAULT: Final[int] = 50 +USAGE_KEY_PAGE_MAX: Final[int] = 100 +USAGE_KEY_SEARCH_DEFAULT: Final[int] = 100 +USAGE_KEY_SEARCH_MAX: Final[int] = 100 +USAGE_MODEL_TOP_KEYS_DEFAULT: Final[int] = 5 +USAGE_MODEL_TOP_KEYS_MAX: Final[int] = 100 +USAGE_CACHE_LEAKAGE_KEYS_DEFAULT: Final[int] = 20 +USAGE_CACHE_LEAKAGE_KEYS_MAX: Final[int] = 100 +USAGE_EXPORT_BATCH_SIZE: Final[int] = 1000 # Furthest back the catch-up pass looks for unpriced PTU days when a deployment # declares no ptu_effective_from, bounding the scan for an open-ended window. PTU_ROLLUP_MAX_BACKFILL_DAYS: Final[int] = 90 diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 663a8e0d6d8..a9d88c50380 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3455,6 +3455,33 @@ }, "DailySpendMetadata": { "properties": { + "api_key_limit": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "When set, api_keys and every api_key_breakdown list at most this many keys, ranked by spend. Totals and the model, provider, mcp and endpoint rollups still cover every key.", + "title": "Api Key Limit" + }, + "entity_total_api_keys": { + "anyOf": [ + { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "description": "Distinct API keys per entity over the requested range, set when the entity breakdown is included. When an entity's count exceeds api_key_limit, its api_key_breakdown lists only its keys among the top api_key_limit keys overall.", + "title": "Entity Total Api Keys" + }, "has_more": { "default": false, "title": "Has More", @@ -3465,6 +3492,18 @@ "title": "Page", "type": "integer" }, + "total_api_keys": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Distinct API keys matching the filters. When this exceeds api_key_limit, the per-key lists are truncated to the highest-spend keys.", + "title": "Total Api Keys" + }, "total_api_requests": { "default": 0, "title": "Total Api Requests", diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index f3564fcbc56..59d1d2bf01d 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -1,13 +1,15 @@ import asyncio from collections.abc import Awaitable, Callable, Mapping, Sequence from collections.abc import Set as AbstractSet -from datetime import datetime, timedelta, timezone -from types import MappingProxyType, SimpleNamespace -from typing import TYPE_CHECKING, Final, Protocol +from dataclasses import dataclass, replace +from datetime import datetime, timedelta +from types import MappingProxyType +from typing import Final, Protocol from fastapi import HTTPException, status from typing_extensions import ReadOnly, TypedDict +from litellm import constants from litellm._logging import verbose_proxy_logger from litellm.constants import PTU_SENTINEL_API_KEY from litellm.proxy._types import CommonProxyErrors @@ -20,11 +22,7 @@ from litellm.proxy.spend_tracking.key_metadata_recovery import ( ) from litellm.proxy.spend_tracking.ptu_feature_flag import is_ptu_cost_attribution_enabled from litellm.proxy.utils import PrismaClient -from litellm.repositories.prisma_protocols import TableActions -from litellm.repositories.table_repositories import DeletedVerificationTokenRepository -from litellm.repositories.verification_token_repository import ( - VerificationTokenRepository, -) +from litellm.repositories.daily_activity_repository import DailyActivityRepository from litellm.types.proxy.management_endpoints.common_daily_activity import ( BreakdownMetrics, DailySpendData, @@ -36,24 +34,15 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, SpendMetrics, ) - -if TYPE_CHECKING: - from prisma.models import ( - LiteLLM_DeletedVerificationToken as PrismaDeletedVerificationToken, - ) - from prisma.models import ( - LiteLLM_VerificationToken as PrismaVerificationToken, - ) - -# Mapping from Prisma accessor names to actual PostgreSQL table names. -_PRISMA_TO_PG_TABLE: Final[Mapping[str, str]] = { - "litellm_dailyuserspend": "LiteLLM_DailyUserSpend", - "litellm_dailyteamspend": "LiteLLM_DailyTeamSpend", - "litellm_dailyorganizationspend": "LiteLLM_DailyOrganizationSpend", - "litellm_dailyenduserspend": "LiteLLM_DailyEndUserSpend", - "litellm_dailyagentspend": "LiteLLM_DailyAgentSpend", - "litellm_dailytagspend": "LiteLLM_DailyTagSpend", -} +from litellm.types.repositories.daily_activity import ( + DailyActivityScope, + DailyActivityTable, + EntityRollupRow, + GroupingSetsRow, + KeyMetadataRow, + RollupMetricsRow, + SpendLogsWindow, +) class DailySpendRecord(Protocol): @@ -132,57 +121,23 @@ class _KeyMetadataDict(TypedDict, total=False): key_exists: ReadOnly[bool] -def _key_metadata(api_key_metadata: Mapping[str, _KeyMetadataDict], api_key: str) -> KeyMetadata: - meta: Final = api_key_metadata.get(api_key, {}) +class _AggregatedSpendData(TypedDict): + results: ReadOnly[list[DailySpendData]] + totals: ReadOnly[SpendMetrics] + + +def _key_metadata(api_key_metadata: Mapping[str, KeyMetadataRow], api_key: str) -> KeyMetadata: + meta: Final = api_key_metadata.get(api_key) return KeyMetadata( - key_alias=meta.get("key_alias"), - team_id=meta.get("team_id"), - user_id=meta.get("user_id"), - user_email=meta.get("user_email"), - key_exists=meta.get("key_exists", False), + key_alias=meta.key_alias if meta is not None else None, + team_id=meta.team_id if meta is not None else None, + user_id=meta.user_id if meta is not None else None, + user_email=meta.user_email if meta is not None else None, + key_exists=meta.key_exists if meta is not None else False, ) -_WhereValue = str | dict[str, object] - - -class _AggregatedSpendData(TypedDict): - results: list[DailySpendData] - totals: SpendMetrics - - -class _GroupingSetsRow(SimpleNamespace): - date: str - api_key: str | None - model: str | None - model_group: str | None - custom_llm_provider: str | None - mcp_namespaced_tool_name: str | None - endpoint: str | None - group_level: int - spend: float | None - prompt_tokens: int | None - completion_tokens: int | None - cache_read_input_tokens: int | None - cache_creation_input_tokens: int | None - compression_saved_tokens: int | None - compression_savings_spend: float | None - prompt_caching_savings_spend: float | None - gateway_injected_caching_savings_spend: float | None - autorouter_savings_spend: float | None - api_requests: int | None - successful_requests: int | None - failed_requests: int | None - total_response_time_ms: int | None - timed_requests: int | None - - -class _EntityRollupRow(_GroupingSetsRow): - entity_id: str | None - api_key_rolled: int - - -def _reported_flat_cost(record: DailySpendRecord | _GroupingSetsRow) -> float: +def _reported_flat_cost(record: DailySpendRecord | RollupMetricsRow) -> float: """Flat cost a daily row reports, which is zero unless PTU cost attribution is enabled. Both read paths funnel through here: the paginated path reads the ``ptu_flat_cost`` @@ -223,9 +178,7 @@ def update_metrics(existing_metrics: SpendMetrics, record: DailySpendRecord) -> existing_metrics.compression_saved_tokens += record.compression_saved_tokens or 0 existing_metrics.compression_savings_spend += record.compression_savings_spend or 0 existing_metrics.prompt_caching_savings_spend += record.prompt_caching_savings_spend or 0 - existing_metrics.gateway_injected_caching_savings_spend += ( # rebind-ok: this accumulator mutates its target in place for every metric on the row - record.gateway_injected_caching_savings_spend or 0 - ) + existing_metrics.gateway_injected_caching_savings_spend += record.gateway_injected_caching_savings_spend or 0 existing_metrics.autorouter_savings_spend += record.autorouter_savings_spend or 0 existing_metrics.api_requests += record.api_requests or 0 existing_metrics.successful_requests += record.successful_requests or 0 @@ -255,7 +208,7 @@ def compute_tag_metadata_totals(records: Sequence[DailySpendRecord]) -> SpendMet if not request_id: continue - tag_value = getattr(record, "tag", None) + tag_value: str | None = getattr(record, "tag", None) if _is_user_agent_tag(tag_value): continue @@ -283,7 +236,7 @@ def update_breakdown_metrics( record: DailySpendRecord, model_metadata: Mapping[str, dict[str, object]], provider_metadata: Mapping[str, dict[str, object]], - api_key_metadata: Mapping[str, _KeyMetadataDict], + api_key_metadata: Mapping[str, KeyMetadataRow], entity_id_field: str | None = None, entity_metadata_field: Mapping[str, dict[str, object]] | None = None, ) -> BreakdownMetrics: @@ -426,8 +379,7 @@ def update_breakdown_metrics( # Update entity-specific metrics if entity_id_field is provided if entity_id_field: - entity_value = getattr(record, entity_id_field, None) - entity_value = entity_value if entity_value else "Unassigned" # allow for null entity_id_field + entity_value: Final[str] = getattr(record, entity_id_field, None) or "Unassigned" if entity_value not in breakdown.entities: breakdown.entities[entity_value] = MetricWithMetadata( metrics=SpendMetrics(), @@ -466,9 +418,6 @@ def _parse_spend_date(raw: str | None) -> datetime | None: return None -_EMPTY_KEY_METADATA: Final[Mapping[str, _KeyMetadataDict]] = MappingProxyType({}) - - def _metadata_with_recovered_owner( metadata: Mapping[str, _KeyMetadataDict], key: str, @@ -480,432 +429,135 @@ def _metadata_with_recovered_owner( return {**current, "user_id": owner} -async def get_api_key_metadata( - prisma_client: PrismaClient, - api_keys: AbstractSet[str], - spend_logs_window: tuple[datetime, datetime] | None = None, -) -> Mapping[str, _KeyMetadataDict]: - """Get api key metadata, falling back to deleted keys table for keys not found in active table. +@dataclass(frozen=True, slots=True) +class _ProxyDailyActivityReads: + prisma_client: PrismaClient - This ensures that key_alias and team_id are preserved in historical activity logs - even after a key is deleted or regenerated. Also recovers aliases for api_key - values that were double-hashed by the v1.99 spend-log provenance gate. - """ - key_records: Sequence[PrismaVerificationToken] = await VerificationTokenRepository(prisma_client).table.find_many( - where={"token": {"in": list(api_keys)}} - ) - result: Final[dict[str, _KeyMetadataDict]] = { - k.token: { - "key_alias": k.key_alias, - "team_id": k.team_id, - "user_id": getattr(k, "user_id", None), - "key_exists": True, + async def recover_key_metadata( + self, resolved: Mapping[str, KeyMetadataRow], api_keys: frozenset[str], window: SpendLogsWindow | None + ) -> Mapping[str, KeyMetadataRow]: + result: Final[dict[str, _KeyMetadataDict]] = { + key: { + "key_alias": row.key_alias, + "team_id": row.team_id, + "user_id": row.user_id, + "user_email": row.user_email, + "key_exists": row.key_exists, + } + for key, row in resolved.items() } - for k in key_records - } - - # For any keys not found in the active table, check the deleted keys table - missing_keys: Final = api_keys - set(result.keys()) - if missing_keys: - try: - deleted_key_records: Final[ - Sequence[PrismaDeletedVerificationToken] - ] = await DeletedVerificationTokenRepository(prisma_client).table.find_many( - where={"token": {"in": list(missing_keys)}}, - order={"deleted_at": "desc"}, - ) - # Use the most recent deleted record for each token (ordered by deleted_at desc) - for k in deleted_key_records: - if k.token not in result: - result[k.token] = { - "key_alias": k.key_alias, - "team_id": k.team_id, - "user_id": getattr(k, "user_id", None), - } - except Exception as e: - verbose_proxy_logger.warning( - "Failed to fetch deleted key metadata for %d missing keys: %s", - len(missing_keys), - e, - ) - - from_session_keys: Final = await recover_cli_session_key_metadata(prisma_client, api_keys - frozenset(result)) - still_missing: Final = api_keys - frozenset(result) - frozenset(from_session_keys) - from_reverse_hash: Final = ( - await recover_double_hashed_key_metadata(prisma_client, still_missing) if still_missing else _EMPTY_KEY_METADATA - ) - after_token_recovery: Final = MappingProxyType({**result, **from_session_keys, **from_reverse_hash}) - unresolved: Final = api_keys - frozenset(after_token_recovery) - from_spend_logs: Final = ( - await recover_key_metadata_from_spend_logs(prisma_client, unresolved, spend_logs_window) - if unresolved and spend_logs_window is not None - else _EMPTY_KEY_METADATA - ) - combined: Final = MappingProxyType({**after_token_recovery, **from_spend_logs}) - ownerless: Final = frozenset( - key - for key in api_keys - if not combined.get(key, {}).get("user_id") and not combined.get(key, {}).get("key_exists") - ) - owners: Final = await recover_key_owner_from_daily_spend(prisma_client, ownerless) - metadata_with_owners: Final[Mapping[str, _KeyMetadataDict]] = MappingProxyType( - { - **combined, - **{key: _metadata_with_recovered_owner(combined, key, owner) for key, owner in owners.items()}, - } - ) - return await attach_user_details(prisma_client, metadata_with_owners) + from_session_keys: Final = await recover_cli_session_key_metadata( + self.prisma_client, api_keys - frozenset(result) + ) + still_missing: Final = api_keys - frozenset(result) - frozenset(from_session_keys) + from_reverse_hash: Final = ( + await recover_double_hashed_key_metadata(self.prisma_client, still_missing) + if still_missing + else MappingProxyType({}) + ) + after_token_recovery: Final = MappingProxyType({**result, **from_session_keys, **from_reverse_hash}) + unresolved: Final = api_keys - frozenset(after_token_recovery) + from_spend_logs: Final = ( + await recover_key_metadata_from_spend_logs(self.prisma_client, unresolved, window) + if unresolved and window is not None + else MappingProxyType({}) + ) + combined: Final = MappingProxyType({**after_token_recovery, **from_spend_logs}) + ownerless: Final = frozenset( + key + for key in api_keys + if not combined.get(key, {}).get("user_id") and not combined.get(key, {}).get("key_exists") + ) + owners: Final = await recover_key_owner_from_daily_spend(self.prisma_client, ownerless) + with_owners: Final[Mapping[str, _KeyMetadataDict]] = MappingProxyType( + { + **combined, + **{key: _metadata_with_recovered_owner(combined, key, owner) for key, owner in owners.items()}, + } + ) + attached: Final = await attach_user_details(self.prisma_client, with_owners) + return MappingProxyType( + { + key: replace( + resolved[key], + key_alias=value.get("key_alias"), + team_id=value.get("team_id"), + user_id=value.get("user_id"), + user_email=value.get("user_email"), + key_exists=value.get("key_exists", False), + ) + if key in resolved + else KeyMetadataRow( + api_key=key, + key_alias=value.get("key_alias"), + team_id=value.get("team_id"), + user_id=value.get("user_id"), + user_email=value.get("user_email"), + key_exists=value.get("key_exists", False), + tags=(), + ) + for key, value in attached.items() + } + ) -def _adjust_dates_for_timezone( +def daily_activity_repository(prisma_client: PrismaClient) -> DailyActivityRepository: + return DailyActivityRepository(prisma_client, proxy_reads=_ProxyDailyActivityReads(prisma_client)) + + +def daily_activity_scope( + table: str, + entity_id_field: str, + entity_id: str | list[str] | None, + exclude_entity_ids: list[str] | None, + api_key: str | list[str] | None, start_date: str, end_date: str, + model: str | None, timezone_offset_minutes: int | None, include_current_utc_day: bool = False, - utc_now: datetime | None = None, -) -> tuple[str, str]: - """ - Map a caller-local date range onto UTC bucket keys, extending only the live end. - - The aggregation table (e.g. LiteLLM_DailyUserSpend) stores spend in whole-UTC-day - buckets keyed on date as YYYY-MM-DD. Any conversion of an interior local-day - boundary using only date arithmetic must round to whole UTC days, allowing up to - 24h of slop at each boundary. A previous implementation expanded the SQL range by - an extra full UTC day on whichever side the offset pointed, which pulled in 24h of - unrelated bucket data per boundary and produced approximately 100% over-counting on - single-day queries (e.g. IST May 29 returning UTC May 28 + UTC May 29 in full). - Sums of single-day queries then exceeded the equivalent multi-day aggregate, which - is mathematically impossible. Historical dates therefore stay a pass-through: the - local date is the UTC bucket key, trading boundary slop for monotonic, additive - results. Hour-level buckets or pro-rata weighting would fix that properly; both - require data the current schema does not store. - - The end boundary is different when the range reaches the caller's current day. A - caller west of UTC asking for a range ending "today" is asking for data up to now, - but once UTC has rolled past their local midnight, everything they sent since then - sits in the next UTC bucket, which the pass-through excludes: a PT dashboard goes - stale every evening from 5pm until local midnight, showing $0 for anything that - only started accruing that evening. Extending such a range to today's UTC bucket - cannot over-count, because the only part of that bucket outside the caller's range - is the future, and the future is empty. ``timezone_offset_minutes`` follows the - JS ``Date.getTimezoneOffset`` convention: UTC minus local, positive west of UTC. - - The extension is strictly opt-in via ``include_current_utc_day`` so a consumer - whose axis or reconciliation expects the range to stop at the requested end date - keeps today's byte-for-byte behaviour; the cost optimization dashboard opts in. - """ - if not include_current_utc_day or timezone_offset_minutes is None: - return start_date, end_date - now: Final = utc_now if utc_now is not None else datetime.now(timezone.utc) - caller_local_today: Final = (now - timedelta(minutes=timezone_offset_minutes)).date().isoformat() - if end_date < caller_local_today: - return start_date, end_date - return start_date, max(end_date, now.date().isoformat()) - - -def _build_where_conditions( - *, - entity_id_field: str, - entity_id: str | list[str] | None, - start_date: str, - end_date: str, - model: str | None, - api_key: str | list[str] | None, - exclude_entity_ids: list[str] | None = None, - timezone_offset_minutes: int | None = None, - include_current_utc_day: bool = False, -) -> dict[str, "_WhereValue"]: - """Build prisma where clause for daily activity queries.""" - # Adjust dates for timezone if provided - adjusted_start, adjusted_end = _adjust_dates_for_timezone( - start_date, end_date, timezone_offset_minutes, include_current_utc_day +) -> DailyActivityScope: + table_value: Final = DailyActivityTable(table) + entity_ids: tuple[str, ...] | None = ( + (entity_id,) if isinstance(entity_id, str) else tuple(entity_id) if entity_id is not None else None + ) + api_keys: tuple[str, ...] | None = ( + None if api_key in (None, "") else (api_key,) if isinstance(api_key, str) else tuple(api_key) + ) + return DailyActivityScope( + table=table_value, + entity_id_field=entity_id_field, + entity_ids=entity_ids, + exclude_entity_ids=tuple(exclude_entity_ids or ()), + api_keys=api_keys, + start_date=start_date, + end_date=end_date, + model=model, + timezone_offset_minutes=timezone_offset_minutes, + include_current_utc_day=include_current_utc_day, ) - where_conditions: Final[dict[str, _WhereValue]] = { - "date": { - "gte": adjusted_start, - "lte": adjusted_end, + +async def get_api_key_metadata( + prisma_client: PrismaClient, api_keys: AbstractSet[str], spend_logs_window: SpendLogsWindow | None = None +) -> Mapping[str, _KeyMetadataDict]: + rows: Final = await daily_activity_repository(prisma_client).key_metadata(frozenset(api_keys), spend_logs_window) + return { + key: { + "key_alias": value.key_alias, + "team_id": value.team_id, + "user_id": value.user_id, + "user_email": value.user_email, + "key_exists": value.key_exists, } + for key, value in rows.items() } - if model: - where_conditions["model"] = model - if api_key: - if isinstance(api_key, list): - where_conditions["api_key"] = {"in": api_key} - else: - where_conditions["api_key"] = api_key - - if entity_id is not None: - if isinstance(entity_id, list): - where_conditions[entity_id_field] = {"in": entity_id} - else: - where_conditions[entity_id_field] = {"equals": entity_id} - - if exclude_entity_ids: - current: _WhereValue = where_conditions.get(entity_id_field, {}) - if isinstance(current, str): - current = {"equals": current} - current["not"] = {"in": exclude_entity_ids} - where_conditions[entity_id_field] = current - - return where_conditions - - -def _build_aggregated_where_clause( - *, - entity_id_field: str, - entity_id: str | list[str] | None, - adjusted_start: str, - adjusted_end: str, - model: str | None, - 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 -) -> tuple[str, list[str]]: - """Build the WHERE clause and $N params shared by the aggregated queries.""" - sql_conditions: Final[list[str]] = [] - sql_params: Final[list[str]] = [] - p = 1 # parameter index (1-based for PostgreSQL $N placeholders) - - # Date range (always present) - sql_conditions.append(f"date >= ${p}") - sql_params.append(adjusted_start) - p += 1 - - sql_conditions.append(f"date <= ${p}") - sql_params.append(adjusted_end) - p += 1 - - # Optional entity filter; an empty list must match nothing, not everything - if entity_id is not None: - if isinstance(entity_id, list): - if entity_id: - placeholders = ", ".join(f"${p + i}" for i in range(len(entity_id))) - sql_conditions.append(f'"{entity_id_field}" IN ({placeholders})') - sql_params.extend(entity_id) - p += len(entity_id) - else: - sql_conditions.append("FALSE") - else: - sql_conditions.append(f'"{entity_id_field}" = ${p}') - sql_params.append(entity_id) - p += 1 - - # Exclude specific entities - if exclude_entity_ids: - placeholders = ", ".join(f"${p + i}" for i in range(len(exclude_entity_ids))) - sql_conditions.append(f'"{entity_id_field}" NOT IN ({placeholders})') - sql_params.extend(exclude_entity_ids) - p += len(exclude_entity_ids) - - # Optional model filter - if model: - sql_conditions.append(f"model = ${p}") - sql_params.append(model) - p += 1 - - # Optional api_key filter; an empty list must match nothing, not everything - if isinstance(api_key, list): - if api_key: - placeholders = ", ".join(f"${p + i}" for i in range(len(api_key))) - sql_conditions.append(f"api_key IN ({placeholders})") - sql_params.extend(api_key) - p += len(api_key) - else: - sql_conditions.append("FALSE") - elif api_key: - sql_conditions.append(f"api_key = ${p}") - sql_params.append(api_key) - p += 1 - - return " AND ".join(sql_conditions), sql_params - - -def _ptu_flat_cost_select(table_name: str) -> str: - """Only LiteLLM_DailyTeamSpend carries ptu_flat_cost; other daily tables emit a - constant zero so the SpendMetrics.flat_cost response shape stays uniform.""" - if table_name == "litellm_dailyteamspend": - return "SUM(ptu_flat_cost)::float AS ptu_flat_cost" - return "0::float AS ptu_flat_cost" - - -def _build_aggregated_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, - model: str | None, - api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - exclude_entity_ids: list[str] | None = None, # mutable-ok: filter union shared with the paginated path - timezone_offset_minutes: int | None = None, - include_current_utc_day: bool = False, -) -> tuple[str, list[str]]: # mutable-ok: SQL text plus its ordered $N params - """Build a parameterized SQL GROUP BY query for aggregated daily activity. - - Groups by (date, api_key, model, model_group, custom_llm_provider, - mcp_namespaced_tool_name, endpoint) with SUMs on all metric columns. - The entity_id column is intentionally omitted from GROUP BY to collapse - rows across entities — this is where the biggest row reduction comes from. - - Returns: - Tuple of (sql_query, params_list) ready for prisma_client.db.query_raw(). - """ - 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, include_current_utc_day - ) - - where_clause, sql_params = _build_aggregated_where_clause( - entity_id_field=entity_id_field, - entity_id=entity_id, - adjusted_start=adjusted_start, - adjusted_end=adjusted_end, - model=model, - api_key=api_key, - exclude_entity_ids=exclude_entity_ids, - ) - - # Postgres computes every rollup level the response needs — per-date - # totals, per-(date, model), per-(date, model, api_key), per-provider, - # etc. — in a single pass via GROUPING SETS. The GROUPING() bitmask - # encodes which level a row belongs to so Python can dispatch rows - # straight into their buckets without re-summing. The leaf grouping - # is omitted on purpose: nothing in the response shape needs it once - # all the rollups are present. - # - # TODO: drop the successful_requests/failed_requests aggregates (and the - # total_successful_requests metadata they feed) once the admin UI reads SGR - # only from LiteLLM_DailyGatewayRequests. The remaining spend, token and - # api_requests rollups are still served from here. - sql_query: Final = f""" - SELECT - date, - api_key, - model, - COALESCE(NULLIF(model_group, ''), model) AS model_group, - custom_llm_provider, - mcp_namespaced_tool_name, - endpoint, - GROUPING(date, api_key, model, COALESCE(NULLIF(model_group, ''), model), - custom_llm_provider, mcp_namespaced_tool_name, - endpoint) AS group_level, - SUM(spend)::float AS spend, - {_ptu_flat_cost_select(table_name)}, - SUM(prompt_tokens)::bigint AS prompt_tokens, - SUM(completion_tokens)::bigint AS completion_tokens, - SUM(cache_read_input_tokens)::bigint AS cache_read_input_tokens, - SUM(cache_creation_input_tokens)::bigint AS cache_creation_input_tokens, - SUM(compression_saved_tokens)::bigint AS compression_saved_tokens, - SUM(compression_savings_spend)::float AS compression_savings_spend, - SUM(prompt_caching_savings_spend)::float AS prompt_caching_savings_spend, - SUM(gateway_injected_caching_savings_spend)::float AS gateway_injected_caching_savings_spend, - SUM(autorouter_savings_spend)::float AS autorouter_savings_spend, - SUM(api_requests)::bigint AS api_requests, - SUM(successful_requests)::bigint AS successful_requests, - SUM(failed_requests)::bigint AS failed_requests, - SUM(total_response_time_ms)::bigint AS total_response_time_ms, - SUM(timed_requests)::bigint AS timed_requests - FROM "{pg_table}" - WHERE {where_clause} - GROUP BY GROUPING SETS ( - (date), - (date, api_key), - (date, model), - (date, model, api_key), - (date, COALESCE(NULLIF(model_group, ''), model)), - (date, COALESCE(NULLIF(model_group, ''), model), api_key), - (date, custom_llm_provider), - (date, custom_llm_provider, api_key), - (date, mcp_namespaced_tool_name), - (date, mcp_namespaced_tool_name, api_key), - (date, endpoint), - (date, endpoint, api_key), - () - ) - """ - - return sql_query, sql_params - - -def _build_entity_rollup_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, - model: str | None, - api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - exclude_entity_ids: list[str] | None = None, # mutable-ok: filter union shared with the paginated path - timezone_offset_minutes: int | None = None, - include_current_utc_day: bool = False, -) -> tuple[str, list[str]]: # mutable-ok: SQL text plus its ordered $N params - """Per-entity companion to _build_aggregated_sql_query. - - Two rollup levels over the same WHERE clause — (date, entity) and - (date, entity, api_key) — told apart by GROUPING(api_key): 1 when the - api_key column is rolled up, 0 when it is part of the key. - """ - 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, include_current_utc_day - ) - - where_clause, sql_params = _build_aggregated_where_clause( - entity_id_field=entity_id_field, - entity_id=entity_id, - adjusted_start=adjusted_start, - adjusted_end=adjusted_end, - model=model, - api_key=api_key, - exclude_entity_ids=exclude_entity_ids, - ) - - sql_query: Final = f""" - SELECT - "{entity_id_field}" AS entity_id, - date, - api_key, - GROUPING(api_key) AS api_key_rolled, - SUM(spend)::float AS spend, - {_ptu_flat_cost_select(table_name)}, - SUM(prompt_tokens)::bigint AS prompt_tokens, - SUM(completion_tokens)::bigint AS completion_tokens, - SUM(cache_read_input_tokens)::bigint AS cache_read_input_tokens, - SUM(cache_creation_input_tokens)::bigint AS cache_creation_input_tokens, - SUM(compression_saved_tokens)::bigint AS compression_saved_tokens, - SUM(compression_savings_spend)::float AS compression_savings_spend, - SUM(prompt_caching_savings_spend)::float AS prompt_caching_savings_spend, - SUM(gateway_injected_caching_savings_spend)::float AS gateway_injected_caching_savings_spend, - SUM(autorouter_savings_spend)::float AS autorouter_savings_spend, - SUM(api_requests)::bigint AS api_requests, - SUM(successful_requests)::bigint AS successful_requests, - SUM(failed_requests)::bigint AS failed_requests, - SUM(total_response_time_ms)::bigint AS total_response_time_ms, - SUM(timed_requests)::bigint AS timed_requests - FROM "{pg_table}" - WHERE {where_clause} - GROUP BY GROUPING SETS ( - (date, "{entity_id_field}"), - (date, "{entity_id_field}", api_key) - ) - """ - - return sql_query, sql_params - def _aggregate_spend_records_sync( *, records: Sequence[DailySpendRecord], - api_key_metadata: Mapping[str, _KeyMetadataDict], + api_key_metadata: Mapping[str, KeyMetadataRow], entity_id_field: str | None, entity_metadata_field: Mapping[str, dict[str, object]] | None, ) -> _AggregatedSpendData: @@ -954,7 +606,7 @@ def _aggregate_spend_records_sync( async def _aggregate_spend_records( *, - prisma_client: PrismaClient, + repository: DailyActivityRepository, records: Sequence[DailySpendRecord], entity_id_field: str | None, entity_metadata_field: Mapping[str, dict[str, object]] | None, @@ -968,11 +620,13 @@ async def _aggregate_spend_records( record.api_key for record in records if record.api_key and record.api_key != PTU_SENTINEL_API_KEY } - api_key_metadata: Mapping[str, _KeyMetadataDict] = MappingProxyType({}) - if api_keys: - api_key_metadata = await get_api_key_metadata( - prisma_client, api_keys, _spend_logs_window(frozenset(record.date for record in records)) + api_key_metadata: Final[Mapping[str, KeyMetadataRow]] = ( + await repository.key_metadata( + frozenset(api_keys), _spend_logs_window(frozenset(record.date for record in records)) ) + if api_keys + else MappingProxyType({}) + ) return await asyncio.to_thread( _aggregate_spend_records_sync, @@ -983,8 +637,7 @@ async def _aggregate_spend_records( ) -# GROUPING() bitmask values for each grouping set emitted by -# _build_aggregated_sql_query. Per Postgres semantics, the rightmost argument +# GROUPING() bitmask values returned by the daily activity repository. Per Postgres semantics, the rightmost argument # is the least-significant bit. Argument order: # date, api_key, model, model_group, custom_llm_provider, # mcp_namespaced_tool_name, endpoint @@ -992,6 +645,7 @@ async def _aggregate_spend_records( # current grouping set's key), 0 when the column is part of the key. _GROUP_GRAND_TOTAL: Final = 127 # 0b1111111 — all rolled up _GROUP_DATE: Final = 63 # 0b0111111 — only date kept +_API_KEY_ROLLED_UP_BIT: Final = 32 # 0b0100000 _GROUP_DATE_API_KEY: Final = 31 # 0b0011111 _GROUP_DATE_MODEL: Final = 47 # 0b0101111 _GROUP_DATE_MODEL_API_KEY: Final = 15 # 0b0001111 @@ -1005,7 +659,7 @@ _GROUP_DATE_ENDPOINT: Final = 62 # 0b0111110 _GROUP_DATE_ENDPOINT_API_KEY: Final = 30 # 0b0011110 -def _record_to_spend_metrics(record: _GroupingSetsRow) -> SpendMetrics: +def _record_to_spend_metrics(record: RollupMetricsRow) -> SpendMetrics: """Build a SpendMetrics directly from one already-aggregated rollup row. SUM() over zero rows is SQL NULL, so rollup rows (notably the grand-total @@ -1036,8 +690,8 @@ def _record_to_spend_metrics(record: _GroupingSetsRow) -> SpendMetrics: def _aggregate_grouping_sets_records_sync( *, - records: Sequence[_GroupingSetsRow], - api_key_metadata: Mapping[str, _KeyMetadataDict], + records: Sequence[GroupingSetsRow], + api_key_metadata: Mapping[str, KeyMetadataRow], ) -> _AggregatedSpendData: """Build the response from rollup rows produced by the GROUPING SETS query. @@ -1162,17 +816,17 @@ def _aggregate_grouping_sets_records_sync( async def _aggregate_grouping_sets_records( *, - prisma_client: PrismaClient, - records: Sequence[_GroupingSetsRow], + repository: DailyActivityRepository, + records: Sequence[GroupingSetsRow], ) -> _AggregatedSpendData: """Async wrapper: fetch api_key_metadata, then dispatch on a worker thread.""" api_keys: Final[set[str]] = {r.api_key for r in records if r.api_key and r.api_key != PTU_SENTINEL_API_KEY} - api_key_metadata: Mapping[str, _KeyMetadataDict] = MappingProxyType({}) - if api_keys: - api_key_metadata = await get_api_key_metadata( - prisma_client, api_keys, _spend_logs_window(frozenset(r.date for r in records)) - ) + api_key_metadata: Final[Mapping[str, KeyMetadataRow]] = ( + await repository.key_metadata(frozenset(api_keys), _spend_logs_window(frozenset(r.date for r in records))) + if api_keys + else MappingProxyType({}) + ) return await asyncio.to_thread( _aggregate_grouping_sets_records_sync, @@ -1200,82 +854,43 @@ async def get_daily_activity( resolve_entity_metadata: Callable[[Sequence[DailySpendRecord]], Awaitable[dict[str, dict[str, object]]]] | None = None, ) -> SpendAnalyticsPaginatedResponse: - """Common function to get daily activity for any entity type. - - ``resolve_entity_metadata`` lets a caller resolve entity metadata from the - rows actually on the page (e.g. user_id -> user_email) instead of fetching - the whole entity table upfront, which matters when the entity set is - unbounded. - """ - if prisma_client is None: - raise HTTPException( - status_code=500, - detail={"error": CommonProxyErrors.db_not_connected_error.value}, - ) - + raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.db_not_connected_error.value}) if start_date is None or end_date is None: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={"error": "Please provide start_date and end_date"}, + status_code=status.HTTP_400_BAD_REQUEST, detail={"error": "Please provide start_date and end_date"} ) - try: - where_conditions: Final = _build_where_conditions( - entity_id_field=entity_id_field, - entity_id=entity_id, - start_date=start_date, - end_date=end_date, - model=model, - api_key=api_key, - exclude_entity_ids=exclude_entity_ids, - timezone_offset_minutes=timezone_offset_minutes, - include_current_utc_day=include_current_utc_day, + scope: Final = daily_activity_scope( + table_name, + entity_id_field, + entity_id, + exclude_entity_ids, + api_key, + start_date, + end_date, + model, + timezone_offset_minutes, + include_current_utc_day, ) - - spend_table: Final[TableActions[DailySpendRecord]] = getattr(prisma_client.db, table_name) - - # Get total count for pagination - total_count: Final[int] = await spend_table.count(where=where_conditions) - - # Fetch paginated results. - # ``date`` alone is not a unique sort key -- a busy tenant has many - # rows per date (one per api_key, model, model_group, provider, - # endpoint, ...), so offset pagination over ``date desc`` lands on - # arbitrary boundaries and the same row can be skipped on one page - # and returned on another. A client that pages through and sums the - # per-page metrics (the Usage dashboard) then gets a non-deterministic - # total. Adding ``id`` (the row's UUID primary key, present on both - # LiteLLM_DailyUserSpend and LiteLLM_DailyTeamSpend) as a tiebreaker - # gives every page a stable cursor (#30164). - daily_spend_data: Final[Sequence[DailySpendRecord]] = await spend_table.find_many( - where=where_conditions, - order=[ - {"date": "desc"}, - {"id": "asc"}, - ], - skip=(page - 1) * page_size, - take=page_size, - ) - + repository: Final = daily_activity_repository(prisma_client) + page_data: Final = await repository.daily_rows(scope, page=page, page_size=page_size) + daily_spend_data: Final = page_data.rows resolved_entity_metadata = entity_metadata_field if resolve_entity_metadata is not None: resolved_entity_metadata = { **(entity_metadata_field or {}), **(await resolve_entity_metadata(daily_spend_data)), } - aggregated: Final = await _aggregate_spend_records( - prisma_client=prisma_client, + repository=repository, records=daily_spend_data, entity_id_field=entity_id_field, entity_metadata_field=resolved_entity_metadata, ) - metadata_metrics = aggregated["totals"] if metadata_metrics_func: metadata_metrics = metadata_metrics_func(daily_spend_data) - return SpendAnalyticsPaginatedResponse( results=aggregated["results"], metadata=DailySpendMetadata( @@ -1297,24 +912,22 @@ async def get_daily_activity( total_response_time_ms=metadata_metrics.total_response_time_ms, total_timed_requests=metadata_metrics.timed_requests, page=page, - total_pages=-(-total_count // page_size), # Ceiling division - has_more=(page * page_size) < total_count, + total_pages=-(-page_data.total_count // page_size), + has_more=(page * page_size) < page_data.total_count, ), ) - - except Exception as e: - verbose_proxy_logger.exception("Error fetching daily activity: %s", e) + except Exception as exc: + verbose_proxy_logger.exception("Error fetching daily activity: %s", exc) raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"error": f"Failed to fetch analytics: {e}"}, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={"error": f"Failed to fetch analytics: {exc}"} ) def _fold_entity_rollups_sync( *, results: Sequence[DailySpendData], - entity_rows: Sequence[_EntityRollupRow], - api_key_metadata: Mapping[str, _KeyMetadataDict], + entity_rows: Sequence[EntityRollupRow], + api_key_metadata: Mapping[str, KeyMetadataRow], entity_metadata_field: Mapping[str, dict[str, object]] | None, # mutable-ok: shared field shape ) -> None: """Write breakdown.entities onto the already-built per-day results.""" @@ -1345,105 +958,42 @@ def _fold_entity_rollups_sync( async def get_daily_activity_aggregated( - prisma_client: PrismaClient | None, - table_name: str, - entity_id_field: str, - entity_id: str | list[str] | None, - entity_metadata_field: Mapping[str, dict[str, object]] | None, - start_date: str | None, - end_date: str | None, - model: str | None, - api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path - exclude_entity_ids: list[str] | None = None, - timezone_offset_minutes: int | None = None, + repository: DailyActivityRepository, + scope: DailyActivityScope, + *, + entity_metadata_field: Mapping[str, dict[str, object]] | None = None, include_entity_breakdown: bool = False, - include_current_utc_day: bool = False, + api_key_limit: int = constants.USAGE_TOP_API_KEYS_DEFAULT, ) -> SpendAnalyticsPaginatedResponse: - """Aggregated variant that returns the full result set (no pagination). - - Uses SQL GROUP BY to aggregate rows in the database rather than fetching - all individual rows into Python. This collapses rows across entities - (users/teams/orgs), reducing ~150k rows to ~2-3k grouped rows. - - include_entity_breakdown runs a small companion rollup query and folds - `breakdown.entities` onto the response, as entity-scoped views like Team Usage need. - - Matches the response model of the paginated endpoint so the UI does not need to transform. - """ - if prisma_client is None: - raise HTTPException( - status_code=500, - detail={"error": CommonProxyErrors.db_not_connected_error.value}, - ) - - if start_date is None or end_date is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={"error": "Please provide start_date and end_date"}, - ) - try: - sql_query, sql_params = _build_aggregated_sql_query( - table_name=table_name, - entity_id_field=entity_id_field, - entity_id=entity_id, - start_date=start_date, - end_date=end_date, - model=model, - api_key=api_key, - exclude_entity_ids=exclude_entity_ids, - timezone_offset_minutes=timezone_offset_minutes, - include_current_utc_day=include_current_utc_day, + aggregated_rows: Final = await repository.aggregated( + scope, include_entity_breakdown=include_entity_breakdown, api_key_limit=api_key_limit ) - - entity_query: Final = ( - _build_entity_rollup_sql_query( - table_name=table_name, - entity_id_field=entity_id_field, - entity_id=entity_id, - start_date=start_date, - end_date=end_date, - model=model, - api_key=api_key, - exclude_entity_ids=exclude_entity_ids, - timezone_offset_minutes=timezone_offset_minutes, - include_current_utc_day=include_current_utc_day, - ) + records: Final = aggregated_rows.grouping_rows + aggregated: Final = await _aggregate_grouping_sets_records( + repository=repository, + records=records, + ) + entity_total_api_keys: Final[dict[str, int] | None] = ( + { + row.entity_id or "Unassigned": row.distinct_api_keys + for row in aggregated_rows.entity_rows or () + if row.api_key_rolled and row.distinct_api_keys is not None + } if include_entity_breakdown else None ) - - # Execute the GROUPING SETS query (one row per rollup level), alongside - # the per-entity companion rollup when the caller wants entities. - raw_rows, raw_entity_rows = ( - await asyncio.gather( - prisma_client.db.query_raw(sql_query, *sql_params), - prisma_client.db.query_raw(entity_query[0], *entity_query[1]), - ) - if entity_query is not None - else (await prisma_client.db.query_raw(sql_query, *sql_params), None) - ) - - records: Final = [_GroupingSetsRow(**row) for row in (raw_rows or [])] - - # The grouping-sets dispatcher places each row directly in its bucket - # using the row's GROUPING() bitmask. No Python-side summing needed. - aggregated: Final = await _aggregate_grouping_sets_records( - prisma_client=prisma_client, - records=records, - ) - - if raw_entity_rows: - entity_records: Final = tuple(_EntityRollupRow(**row) for row in raw_entity_rows) + if aggregated_rows.entity_rows: + entity_records: Final = aggregated_rows.entity_rows entity_api_keys: Final = frozenset( - r.api_key for r in entity_records if r.api_key and r.api_key != PTU_SENTINEL_API_KEY + row.api_key for row in entity_records if row.api_key and row.api_key != PTU_SENTINEL_API_KEY ) - entity_key_metadata: Final = ( - await get_api_key_metadata( - prisma_client, entity_api_keys, _spend_logs_window(frozenset(r.date for r in entity_records)) + entity_key_metadata: Final[Mapping[str, KeyMetadataRow]] = ( + await repository.key_metadata( + entity_api_keys, _spend_logs_window(frozenset(row.date for row in entity_records)) ) if entity_api_keys - else {} + else MappingProxyType({}) ) await asyncio.to_thread( _fold_entity_rollups_sync, @@ -1452,7 +1002,6 @@ async def get_daily_activity_aggregated( api_key_metadata=entity_key_metadata, entity_metadata_field=entity_metadata_field, ) - return SpendAnalyticsPaginatedResponse( results=aggregated["results"], metadata=DailySpendMetadata( @@ -1478,12 +1027,13 @@ async def get_daily_activity_aggregated( page=1, total_pages=1, has_more=False, + api_key_limit=api_key_limit, + total_api_keys=aggregated_rows.distinct_api_keys, + entity_total_api_keys=entity_total_api_keys, ), ) - - except Exception as e: - verbose_proxy_logger.exception("Error fetching aggregated daily activity: %s", e) + except Exception as exc: + verbose_proxy_logger.exception("Error fetching aggregated daily activity: %s", exc) raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"error": f"Failed to fetch analytics: {e}"}, + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={"error": f"Failed to fetch analytics: {exc}"} ) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index f6fe58e25b9..1fd63c6d456 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -54,6 +54,8 @@ from litellm.proxy.hooks.user_management_event_hooks import UserManagementEventH from litellm.proxy.management.teams.access import is_team_admin from litellm.proxy.management_endpoints.common_daily_activity import ( DailySpendRecord, + daily_activity_repository, + daily_activity_scope, get_daily_activity, get_daily_activity_aggregated, ) @@ -3079,18 +3081,22 @@ async def get_user_daily_activity_aggregated( ) entity_id = user_id + repository: Final = daily_activity_repository(prisma_client) + scope: Final = daily_activity_scope( + "litellm_dailyuserspend", + "user_id", + entity_id, + None, + api_key, + start_date, + end_date, + model, + timezone, + include_current_utc_day, + ) return await get_daily_activity_aggregated( - prisma_client=prisma_client, - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id=entity_id, - entity_metadata_field=None, - start_date=start_date, - end_date=end_date, - model=model, - api_key=api_key, - timezone_offset_minutes=timezone, - include_current_utc_day=include_current_utc_day, + repository, + scope, ) except HTTPException: diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 68f635f7799..040b27b5802 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -125,6 +125,8 @@ from litellm.proxy.hooks.model_max_budget_limiter import ( from litellm.proxy.management.teams.access import TEAM_OR_ORG_ADMIN, TeamRole, is_team_admin, team_access_denied from litellm.proxy.management.teams.dependencies import get_team_access from litellm.proxy.management_endpoints.common_daily_activity import ( + daily_activity_repository, + daily_activity_scope, get_daily_activity_aggregated, ) from litellm.proxy.management_endpoints.common_utils import ( @@ -6761,18 +6763,22 @@ async def get_team_daily_activity_aggregated( proxy_logging_obj=proxy_logging_obj, ) + repository: Final = daily_activity_repository(prisma_client) + activity_scope: Final = daily_activity_scope( + "litellm_dailyteamspend", + "team_id", + scope.team_ids, + scope.exclude_team_ids, + scope.api_key_filter, + start_date, + end_date, + model, + timezone, + ) return await get_daily_activity_aggregated( - prisma_client=prisma_client, - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=scope.team_ids, + repository, + activity_scope, entity_metadata_field=scope.team_alias_metadata, - start_date=start_date, - end_date=end_date, - model=model, - api_key=scope.api_key_filter, - exclude_entity_ids=scope.exclude_team_ids, - timezone_offset_minutes=timezone, include_entity_breakdown=True, ) diff --git a/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py b/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py index 1265da99d89..3c2300f14fd 100644 --- a/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py +++ b/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py @@ -8,11 +8,13 @@ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, from datetime import date from typing import Final, Literal, NamedTuple, Protocol, cast, overload +from fastapi import HTTPException from typing_extensions import ReadOnly, TypedDict import litellm from litellm._logging import verbose_proxy_logger from litellm.constants import DEFAULT_COMPETITOR_DISCOVERY_MODEL +from litellm.proxy._types import CommonProxyErrors from litellm.types.proxy.management_endpoints.common_daily_activity import ( SpendAnalyticsPaginatedResponse, ) @@ -259,22 +261,34 @@ async def _query_activity( ) -> SpendAnalyticsPaginatedResponse: """Shared helper that calls the daily activity query layer.""" from litellm.proxy.management_endpoints.common_daily_activity import ( + daily_activity_repository, + daily_activity_scope, get_daily_activity, get_daily_activity_aggregated, ) from litellm.proxy.proxy_server import prisma_client if use_aggregated: + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + repository: Final = daily_activity_repository(prisma_client) + scope: Final = daily_activity_scope( + table_name, + entity_id_field, + entity_id, + None, + None, + start_date, + end_date, + None, + None, + ) return await get_daily_activity_aggregated( - prisma_client=prisma_client, - table_name=table_name, - entity_id_field=entity_id_field, - entity_id=entity_id, - entity_metadata_field=None, - start_date=start_date, - end_date=end_date, - model=None, - api_key=None, + repository, + scope, ) return await get_daily_activity( prisma_client=prisma_client, diff --git a/litellm/repositories/daily_activity_repository.py b/litellm/repositories/daily_activity_repository.py new file mode 100644 index 00000000000..e9d8c3bd309 --- /dev/null +++ b/litellm/repositories/daily_activity_repository.py @@ -0,0 +1,304 @@ +import asyncio +from collections.abc import AsyncIterator, Mapping, Sequence +from datetime import datetime +from itertools import groupby +from types import MappingProxyType +from typing import Final, Protocol + +from pydantic import StrictStr, TypeAdapter, ValidationError +from typing_extensions import assert_never + +from litellm import constants +from litellm._logging import verbose_proxy_logger +from litellm.repositories.chunked_in import find_many_in +from litellm.repositories.daily_activity_sql import ( + ExportCursor, + SqlQuery, + adjust_dates_for_timezone, + build_aggregated_sql, + build_cache_leakage_keys_sql, + build_entity_rollup_sql, + build_export_sql, + build_key_page_sql, + build_key_search_sql, + build_model_top_keys_sql, +) +from litellm.repositories.prisma_protocols import TableActions +from litellm.types.repositories.daily_activity import ( + AggregatedRows, + DailyActivityProxyReads, + DailyActivityRow, + DailyActivityScope, + DailyActivityTable, + DailyRowsPage, + EntityRollupRow, + ExportRow, + ExportType, + GroupingSetsRow, + KeyMetadataRow, + KeyPage, + KeySpendRow, + SpendLogsWindow, +) + + +class _VerificationTokenRow(Protocol): + token: str + key_alias: str | None + team_id: str | None + user_id: str | None + metadata: Mapping[str, object] | None + + +class _DeletedVerificationTokenRow(_VerificationTokenRow, Protocol): + deleted_at: datetime + + +class _QueryRaw(Protocol): + async def __call__(self, query: str, *values: object) -> Sequence[Mapping[str, object]] | None: ... + + +class _DailyActivityDatabase(Protocol): + query_raw: _QueryRaw + litellm_verificationtoken: TableActions[_VerificationTokenRow] + litellm_deletedverificationtoken: TableActions[_DeletedVerificationTokenRow] + + @property + def litellm_dailyuserspend(self) -> TableActions[DailyActivityRow]: ... + + @property + def litellm_dailyteamspend(self) -> TableActions[DailyActivityRow]: ... + + @property + def litellm_dailytagspend(self) -> TableActions[DailyActivityRow]: ... + + @property + def litellm_dailyorganizationspend(self) -> TableActions[DailyActivityRow]: ... + + @property + def litellm_dailyenduserspend(self) -> TableActions[DailyActivityRow]: ... + + @property + def litellm_dailyagentspend(self) -> TableActions[DailyActivityRow]: ... + + +class DailyActivityDatabase(Protocol): + @property + def db(self) -> _DailyActivityDatabase: ... + + +_GROUPING_ADAPTER: Final = TypeAdapter(tuple[GroupingSetsRow, ...]) +_ENTITY_ADAPTER: Final = TypeAdapter(tuple[EntityRollupRow, ...]) +_KEY_SPEND_ADAPTER: Final = TypeAdapter(tuple[KeySpendRow, ...]) +_KEY_PAGE_TOTAL_ADAPTER: Final[TypeAdapter[int]] = TypeAdapter(int) +_EXPORT_ADAPTER: Final = TypeAdapter(tuple[ExportRow, ...]) +_METADATA_TAGS_ADAPTER: Final = TypeAdapter(list[StrictStr]) + + +def _metadata_tags(value: object) -> tuple[str, ...]: + stable_value: Final = value + if not isinstance(value, list): + return () + try: + return tuple(_METADATA_TAGS_ADAPTER.validate_python(stable_value)) + except ValidationError: + return () + + +def _daily_rows_table( + prisma_client: DailyActivityDatabase, table: DailyActivityTable +) -> TableActions[DailyActivityRow]: + if table is DailyActivityTable.USER: + return prisma_client.db.litellm_dailyuserspend + if table is DailyActivityTable.TEAM: + return prisma_client.db.litellm_dailyteamspend + if table is DailyActivityTable.TAG: + return prisma_client.db.litellm_dailytagspend + if table is DailyActivityTable.ORGANIZATION: + return prisma_client.db.litellm_dailyorganizationspend + if table is DailyActivityTable.CUSTOMER: + return prisma_client.db.litellm_dailyenduserspend + if table is DailyActivityTable.AGENT: + return prisma_client.db.litellm_dailyagentspend + assert_never(table) + raise AssertionError("unreachable") + + +def _next_export_cursor(batch: tuple[ExportRow, ...], export_type: ExportType) -> ExportCursor: + last: Final = batch[-1] + cursor_key: Final = ( + last.api_key + if export_type is ExportType.DAILY_WITH_KEYS + else last.model + if export_type is ExportType.DAILY_WITH_MODELS + else last.user_id + if export_type is ExportType.DAILY_WITH_USERS + else "" + ) + return ExportCursor(date=last.date, entity_id=last.entity_id, group_key=cursor_key or "") + + +class DailyActivityRepository: + def __init__(self, prisma_client: DailyActivityDatabase, *, proxy_reads: DailyActivityProxyReads) -> None: + self._prisma_client = prisma_client + self._proxy_reads = proxy_reads + + async def _query(self, query: SqlQuery) -> tuple[Mapping[str, object], ...]: + first_line: Final = query.sql.lstrip().splitlines()[0].lstrip("(").strip() + verbose_proxy_logger.debug("DailyActivityRepository query: %s", first_line) + result: Sequence[Mapping[str, object]] | None = await self._prisma_client.db.query_raw(query.sql, *query.params) + if result is None: + return () + return tuple(result) + + async def aggregated( + self, scope: DailyActivityScope, *, include_entity_breakdown: bool, api_key_limit: int + ) -> AggregatedRows: + grouping_query: Final = build_aggregated_sql(scope, api_key_limit=api_key_limit) + entity_query: Final = ( + build_entity_rollup_sql(scope, api_key_limit=api_key_limit) if include_entity_breakdown else None + ) + grouping_result, entity_result = await asyncio.gather( + self._query(grouping_query), + self._query(entity_query) if entity_query is not None else asyncio.sleep(0, result=None), + ) + grouping_rows: Final = _GROUPING_ADAPTER.validate_python(grouping_result) + entity_rows: Final = None if entity_result is None else _ENTITY_ADAPTER.validate_python(entity_result) + distinct_api_keys: Final = next( + (row.distinct_api_keys for row in grouping_rows if row.distinct_api_keys is not None), 0 + ) + return AggregatedRows( + grouping_rows=grouping_rows, + entity_rows=entity_rows, + distinct_api_keys=distinct_api_keys, + ) + + async def search_keys(self, scope: DailyActivityScope, *, search: str, limit: int) -> tuple[str, ...]: + if not 1 <= limit <= constants.USAGE_KEY_SEARCH_MAX: + raise ValueError(f"limit must be between 1 and {constants.USAGE_KEY_SEARCH_MAX}") + query: Final = build_key_search_sql(scope, search=search, limit=limit) + rows: Final = _KEY_SPEND_ADAPTER.validate_python(await self._query(query)) + return tuple(row.api_key for row in rows) + + async def key_page(self, scope: DailyActivityScope, *, offset: int, limit: int) -> KeyPage: + query: Final = build_key_page_sql(scope, offset=offset, limit=limit) + result: Final = await self._query(query) + total_api_keys_value: Final = result[0].get("total_api_keys") if result else 0 + total_api_keys: Final = ( + _KEY_PAGE_TOTAL_ADAPTER.validate_python(total_api_keys_value) if total_api_keys_value is not None else 0 + ) + rows: Final = _KEY_SPEND_ADAPTER.validate_python(tuple(row for row in result if row.get("api_key") is not None)) + return KeyPage(rows=rows, total_api_keys=total_api_keys) + + async def model_top_keys( + self, scope: DailyActivityScope, *, model_group: str, by_model_group: bool, limit: int + ) -> tuple[KeySpendRow, ...]: + if not 1 <= limit <= constants.USAGE_MODEL_TOP_KEYS_MAX: + raise ValueError(f"limit must be between 1 and {constants.USAGE_MODEL_TOP_KEYS_MAX}") + query: Final = build_model_top_keys_sql( + scope, + model_group=model_group, + by_model_group=by_model_group, + limit=limit, + ) + return _KEY_SPEND_ADAPTER.validate_python(await self._query(query)) + + async def cache_leakage_keys(self, scope: DailyActivityScope, *, limit: int) -> tuple[KeySpendRow, ...]: + if not 1 <= limit <= constants.USAGE_CACHE_LEAKAGE_KEYS_MAX: + raise ValueError(f"limit must be between 1 and {constants.USAGE_CACHE_LEAKAGE_KEYS_MAX}") + query: Final = build_cache_leakage_keys_sql(scope, limit=limit) + return _KEY_SPEND_ADAPTER.validate_python(await self._query(query)) + + async def export_rows(self, scope: DailyActivityScope, *, export_type: ExportType) -> AsyncIterator[ExportRow]: + batch_size: Final = constants.USAGE_EXPORT_BATCH_SIZE + cursor: ExportCursor | None = None # rebind-ok: each page advances the export keyset cursor + while True: + batch: tuple[ExportRow, ...] = _EXPORT_ADAPTER.validate_python( + await self._query(build_export_sql(scope, export_type=export_type, after=cursor, batch_size=batch_size)) + ) + for row in batch: + yield row + if len(batch) < batch_size: + return + cursor = _next_export_cursor(batch, export_type) + + async def _active_token_rows(self, values: tuple[str, ...]) -> tuple[_VerificationTokenRow, ...]: + return await find_many_in(self._prisma_client.db.litellm_verificationtoken, "token", values) + + async def _deleted_token_rows(self, values: tuple[str, ...]) -> tuple[_DeletedVerificationTokenRow, ...]: + try: + return await find_many_in(self._prisma_client.db.litellm_deletedverificationtoken, "token", values) + except Exception as exc: + verbose_proxy_logger.warning("Could not read deleted verification token metadata: %s", exc) + return () + + async def key_metadata( + self, api_keys: frozenset[str], window: SpendLogsWindow | None + ) -> Mapping[str, KeyMetadataRow]: + if not api_keys: + return {} + values: Final = tuple(api_keys) + active_rows: Final = await self._active_token_rows(values) + active: Final = MappingProxyType({row.token: self._metadata_row(row, key_exists=True) for row in active_rows}) + missing: Final = tuple(key for key in values if key not in active) + deleted_rows: Final = await self._deleted_token_rows(missing) + deleted_by_token: Final = MappingProxyType( + { + token: max(rows, key=lambda row: row.deleted_at) + for token, rows in groupby( + sorted(deleted_rows, key=lambda row: row.token), + key=lambda row: row.token, + ) + } + ) + deleted: Final = MappingProxyType( + { + key: self._metadata_row(deleted_by_token[key], key_exists=False) + for key in missing + if key in deleted_by_token + } + ) + resolved: Final = MappingProxyType({**deleted, **active}) + return await self._proxy_reads.recover_key_metadata(resolved, api_keys, window) + + @staticmethod + def _metadata_row(row: _VerificationTokenRow, *, key_exists: bool) -> KeyMetadataRow: + tags: Final = _metadata_tags(row.metadata.get("tags") if row.metadata is not None else None) + return KeyMetadataRow( + api_key=row.token, + key_alias=row.key_alias, + team_id=row.team_id, + user_id=row.user_id, + user_email=None, + key_exists=key_exists, + tags=tags, + ) + + async def daily_rows(self, scope: DailyActivityScope, *, page: int, page_size: int) -> DailyRowsPage: + table: Final = _daily_rows_table(self._prisma_client, scope.table) + adjusted_start, adjusted_end = adjust_dates_for_timezone( + scope.start_date, + scope.end_date, + scope.timezone_offset_minutes, + include_current_utc_day=scope.include_current_utc_day, + ) + entity_filter: Final = { + **({"in": list(scope.entity_ids)} if scope.entity_ids is not None else {}), + **({"not": {"in": list(scope.exclude_entity_ids)}} if scope.exclude_entity_ids else {}), + } + conditions: Final = { + "date": {"gte": adjusted_start, "lte": adjusted_end}, + **({scope.entity_id_field: entity_filter} if entity_filter else {}), + **({"model": scope.model} if scope.model else {}), + **({"api_key": {"in": list(scope.api_keys)}} if scope.api_keys is not None else {}), + } + count, rows = await asyncio.gather( + table.count(where=conditions), + table.find_many( + where=conditions, + skip=(page - 1) * page_size, + take=page_size, + order=({"date": "desc"}, {"id": "asc"}), + ), + ) + return DailyRowsPage(total_count=count, rows=tuple(rows)) diff --git a/litellm/repositories/daily_activity_sql.py b/litellm/repositories/daily_activity_sql.py new file mode 100644 index 00000000000..96920b55eb0 --- /dev/null +++ b/litellm/repositories/daily_activity_sql.py @@ -0,0 +1,519 @@ +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from itertools import count, islice +from types import MappingProxyType +from typing import Final + +from typing_extensions import assert_never + +from litellm import constants +from litellm.constants import PTU_SENTINEL_API_KEY +from litellm.types.repositories.daily_activity import DailyActivityScope, DailyActivityTable, ExportType + +_API_KEY_ROLLED_UP_BIT: Final = 32 +_MODEL_GROUP_EXPR: Final = "COALESCE(NULLIF(model_group, ''), model)" + + +@dataclass(frozen=True, slots=True) +class SqlQuery: + sql: str + params: tuple[object, ...] + + +@dataclass(frozen=True, slots=True) +class ExportCursor: + date: str + entity_id: str + group_key: str + + +PRISMA_TO_PG_TABLE: Final[Mapping[DailyActivityTable, str]] = MappingProxyType( + { + DailyActivityTable.USER: "LiteLLM_DailyUserSpend", + DailyActivityTable.TEAM: "LiteLLM_DailyTeamSpend", + DailyActivityTable.TAG: "LiteLLM_DailyTagSpend", + DailyActivityTable.ORGANIZATION: "LiteLLM_DailyOrganizationSpend", + DailyActivityTable.CUSTOMER: "LiteLLM_DailyEndUserSpend", + DailyActivityTable.AGENT: "LiteLLM_DailyAgentSpend", + } +) + + +def adjust_dates_for_timezone( + start_date: str, + end_date: str, + timezone_offset_minutes: int | None, + include_current_utc_day: bool = False, + utc_now: datetime | None = None, +) -> tuple[str, str]: + if not include_current_utc_day or timezone_offset_minutes is None: + return start_date, end_date + now: Final = utc_now if utc_now is not None else datetime.now(timezone.utc) + caller_local_today: Final = (now - timedelta(minutes=timezone_offset_minutes)).date().isoformat() + if end_date < caller_local_today: + return start_date, end_date + return start_date, max(end_date, now.date().isoformat()) + + +def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tuple[str, tuple[object, ...]]: + adjusted_start, adjusted_end = adjust_dates_for_timezone( + scope.start_date, + scope.end_date, + scope.timezone_offset_minutes, + scope.include_current_utc_day, + ) + entity_index: Final = start_index + 2 + has_entity_array: Final = scope.entity_ids is not None and bool(scope.entity_ids) + exclusion_index: Final = entity_index + int(has_entity_array) + model_index: Final = exclusion_index + int(bool(scope.exclude_entity_ids)) + api_keys_index: Final = model_index + int(bool(scope.model)) + conditions: Final = ( + f"date >= ${start_index}", + f"date <= ${start_index + 1}", + *( + ("FALSE",) + if scope.entity_ids == () + else (f'"{scope.entity_id_field}" = ANY(${entity_index}::text[])',) + if has_entity_array + else () + ), + *((f'NOT ("{scope.entity_id_field}" = ANY(${exclusion_index}::text[]))',) if scope.exclude_entity_ids else ()), + *((f"model = ${model_index}",) if scope.model else ()), + *( + ("FALSE",) + if scope.api_keys == () + else (f"api_key = ANY(${api_keys_index}::text[])",) + if scope.api_keys + else () + ), + ) + params: Final = ( + adjusted_start, + adjusted_end, + *((list(scope.entity_ids or ()),) if has_entity_array else ()), + *((list(scope.exclude_entity_ids),) if scope.exclude_entity_ids else ()), + *((scope.model,) if scope.model else ()), + *((list(scope.api_keys),) if scope.api_keys else ()), + ) + return " AND ".join(conditions), params + + +def _ptu_flat_cost_select(table: DailyActivityTable, *, aggregate: bool = True) -> str: + if table is DailyActivityTable.TEAM: + return "SUM(ptu_flat_cost)::float AS ptu_flat_cost" if aggregate else "SUM(scoped.ptu_flat_cost)::float" + return "0::float AS ptu_flat_cost" if aggregate else "0::float" + + +def _rollup_metric_select(table: DailyActivityTable) -> str: + return f""" + SUM(spend)::float AS spend, + {_ptu_flat_cost_select(table)}, + SUM(prompt_tokens)::bigint AS prompt_tokens, + SUM(completion_tokens)::bigint AS completion_tokens, + SUM(cache_read_input_tokens)::bigint AS cache_read_input_tokens, + SUM(cache_creation_input_tokens)::bigint AS cache_creation_input_tokens, + SUM(compression_saved_tokens)::bigint AS compression_saved_tokens, + SUM(compression_savings_spend)::float AS compression_savings_spend, + SUM(prompt_caching_savings_spend)::float AS prompt_caching_savings_spend, + SUM(gateway_injected_caching_savings_spend)::float AS gateway_injected_caching_savings_spend, + SUM(autorouter_savings_spend)::float AS autorouter_savings_spend, + SUM(api_requests)::bigint AS api_requests, + SUM(successful_requests)::bigint AS successful_requests, + SUM(failed_requests)::bigint AS failed_requests, + SUM(total_response_time_ms)::bigint AS total_response_time_ms, + SUM(timed_requests)::bigint AS timed_requests""" + + +def _validate_api_key_limit(api_key_limit: int) -> None: + if not 1 <= api_key_limit <= constants.USAGE_TOP_API_KEYS_MAX: + raise ValueError(f"api_key_limit must be between 1 and {constants.USAGE_TOP_API_KEYS_MAX}") + + +def _top_api_keys_sql(pg_table: str, where_clause: str, *, sentinel_param: int, limit_param: int) -> str: + return f""" + SELECT api_key, COUNT(*) OVER () AS distinct_api_keys + FROM "{pg_table}" + WHERE {where_clause} AND api_key <> ${sentinel_param} + GROUP BY api_key + ORDER BY SUM(spend::numeric) DESC, api_key + LIMIT ${limit_param} + """ + + +def build_aggregated_sql(scope: DailyActivityScope, *, api_key_limit: int) -> SqlQuery: + pg_table: Final = PRISMA_TO_PG_TABLE[scope.table] + where_clause, where_params = build_where_clause(scope) + _validate_api_key_limit(api_key_limit) + sentinel_param: Final = len(where_params) + 1 + top_keys_limit_param: Final = len(where_params) + 2 + top_api_keys: Final = _top_api_keys_sql( + pg_table, where_clause, sentinel_param=sentinel_param, limit_param=top_keys_limit_param + ) + metric_select: Final = _rollup_metric_select(scope.table) + sql: Final = f""" + (SELECT + date, + NULL::text AS api_key, + model, + {_MODEL_GROUP_EXPR} AS model_group, + custom_llm_provider, + mcp_namespaced_tool_name, + endpoint, + (GROUPING(date) << 6) | {_API_KEY_ROLLED_UP_BIT} + | GROUPING(model, {_MODEL_GROUP_EXPR}, + custom_llm_provider, mcp_namespaced_tool_name, + endpoint) AS group_level, + NULL::bigint AS distinct_api_keys,{metric_select} + FROM "{pg_table}" + WHERE {where_clause} + GROUP BY GROUPING SETS ( + (date), + (date, model), + (date, {_MODEL_GROUP_EXPR}), + (date, custom_llm_provider), + (date, mcp_namespaced_tool_name), + (date, endpoint), + () + )) + UNION ALL + (WITH top_api_keys AS ( + {top_api_keys} + ) + SELECT + date, + api_key, + model, + {_MODEL_GROUP_EXPR} AS model_group, + custom_llm_provider, + mcp_namespaced_tool_name, + endpoint, + GROUPING(date, api_key, model, {_MODEL_GROUP_EXPR}, + custom_llm_provider, mcp_namespaced_tool_name, + endpoint) AS group_level, + MAX(top_api_keys.distinct_api_keys) AS distinct_api_keys,{metric_select} + FROM "{pg_table}" JOIN top_api_keys USING (api_key) + WHERE {where_clause} + GROUP BY GROUPING SETS ( + (date, api_key), + (date, model, api_key), + (date, {_MODEL_GROUP_EXPR}, api_key), + (date, custom_llm_provider, api_key), + (date, mcp_namespaced_tool_name, api_key), + (date, endpoint, api_key) + )) + """ + return SqlQuery( + sql=sql, + params=(*where_params, PTU_SENTINEL_API_KEY, api_key_limit), + ) + + +def build_entity_rollup_sql(scope: DailyActivityScope, *, api_key_limit: int) -> SqlQuery: + pg_table: Final = PRISMA_TO_PG_TABLE[scope.table] + where_clause, where_params = build_where_clause(scope) + _validate_api_key_limit(api_key_limit) + sentinel_param: Final = len(where_params) + 1 + top_keys_limit_param: Final = len(where_params) + 2 + top_api_keys: Final = _top_api_keys_sql( + pg_table, where_clause, sentinel_param=sentinel_param, limit_param=top_keys_limit_param + ) + metric_select: Final = _rollup_metric_select(scope.table) + sql: Final = f""" + WITH top_api_keys AS ( + {top_api_keys} + ), + entity_api_keys AS ( + SELECT COALESCE("{scope.entity_id_field}", '') AS entity_id, + COUNT(DISTINCT api_key)::bigint AS distinct_api_keys + FROM "{pg_table}" + WHERE {where_clause} AND api_key <> ${sentinel_param} + GROUP BY COALESCE("{scope.entity_id_field}", '') + ) + (SELECT e.*, COALESCE(k.distinct_api_keys, 0)::bigint AS distinct_api_keys + FROM ( + SELECT COALESCE("{scope.entity_id_field}", '') AS entity_id, + date, + NULL::text AS api_key, + 1 AS api_key_rolled,{metric_select} + FROM "{pg_table}" + WHERE {where_clause} + GROUP BY date, COALESCE("{scope.entity_id_field}", '') + ) e + LEFT JOIN entity_api_keys k ON k.entity_id = e.entity_id) + UNION ALL + (SELECT COALESCE("{scope.entity_id_field}", '') AS entity_id, + date, + api_key, + 0 AS api_key_rolled,{metric_select}, + NULL::bigint AS distinct_api_keys + FROM "{pg_table}" JOIN top_api_keys USING (api_key) + WHERE {where_clause} + GROUP BY date, COALESCE("{scope.entity_id_field}", ''), api_key) + """ + return SqlQuery(sql=sql, params=(*where_params, PTU_SENTINEL_API_KEY, api_key_limit)) + + +def _key_spend_select() -> str: + return """ + COALESCE(SUM(spend), 0)::float AS spend, + COALESCE(SUM(prompt_tokens), 0)::bigint AS prompt_tokens, + COALESCE(SUM(completion_tokens), 0)::bigint AS completion_tokens, + (COALESCE(SUM(prompt_tokens), 0) + COALESCE(SUM(completion_tokens), 0))::bigint AS total_tokens, + COALESCE(SUM(api_requests), 0)::bigint AS api_requests, + COALESCE(SUM(successful_requests), 0)::bigint AS successful_requests, + COALESCE(SUM(failed_requests), 0)::bigint AS failed_requests, + COALESCE(SUM(cache_read_input_tokens), 0)::bigint AS cache_read_input_tokens, + COALESCE(SUM(cache_creation_input_tokens), 0)::bigint AS cache_creation_input_tokens""" + + +def build_key_page_sql(scope: DailyActivityScope, *, offset: int, limit: int) -> SqlQuery: + if not 1 <= limit <= constants.USAGE_KEY_PAGE_MAX: + raise ValueError(f"limit must be between 1 and {constants.USAGE_KEY_PAGE_MAX}") + if offset < 0: + raise ValueError("offset must be non-negative") + where_clause, where_params = build_where_clause(scope) + sentinel_param: Final = len(where_params) + 1 + limit_param: Final = sentinel_param + 1 + offset_param: Final = limit_param + 1 + sql: Final = f""" + WITH ranked AS ( + SELECT api_key,{_key_spend_select()}, SUM(spend::numeric) AS rank_spend + FROM "{PRISMA_TO_PG_TABLE[scope.table]}" + WHERE {where_clause} AND api_key <> ${sentinel_param} + GROUP BY api_key + ) + SELECT (SELECT COUNT(*) FROM ranked)::bigint AS total_api_keys, page.* + FROM (SELECT 1) AS one + LEFT JOIN LATERAL ( + SELECT * FROM ranked + ORDER BY rank_spend DESC, api_key + LIMIT ${limit_param} OFFSET ${offset_param} + ) AS page ON TRUE + """ + return SqlQuery(sql=sql, params=(*where_params, PTU_SENTINEL_API_KEY, limit, offset)) + + +def _bounded_limit(limit: int, *, minimum: int = 1) -> None: + if limit < minimum: + raise ValueError(f"limit must be at least {minimum}") + + +def build_key_search_sql(scope: DailyActivityScope, *, search: str, limit: int) -> SqlQuery: + _bounded_limit(limit) + where_clause, where_params = build_where_clause(scope) + search_param: Final = len(where_params) + 1 + sentinel_param: Final = search_param + 1 + limit_param: Final = sentinel_param + 1 + escaped: Final = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + sql: Final = f""" + SELECT api_key,{_key_spend_select()} + FROM "{PRISMA_TO_PG_TABLE[scope.table]}" + WHERE {where_clause} + AND api_key <> ${sentinel_param} + AND ( + api_key ILIKE ${search_param} ESCAPE '\\' + OR api_key IN ( + SELECT v.token FROM "LiteLLM_VerificationToken" v + LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = v.user_id + WHERE v.key_alias ILIKE ${search_param} ESCAPE '\\' + OR v.user_id ILIKE ${search_param} ESCAPE '\\' + OR u.user_email ILIKE ${search_param} ESCAPE '\\' + UNION + SELECT d.token FROM "LiteLLM_DeletedVerificationToken" d + LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = d.user_id + WHERE d.key_alias ILIKE ${search_param} ESCAPE '\\' + OR d.user_id ILIKE ${search_param} ESCAPE '\\' + OR u.user_email ILIKE ${search_param} ESCAPE '\\' + ) + ) + GROUP BY api_key + ORDER BY SUM(spend::numeric) DESC, api_key + LIMIT ${limit_param} + """ + return SqlQuery(sql=sql, params=(*where_params, f"%{escaped}%", PTU_SENTINEL_API_KEY, limit)) + + +def build_model_top_keys_sql( + scope: DailyActivityScope, *, model_group: str, by_model_group: bool, limit: int +) -> SqlQuery: + _bounded_limit(limit) + where_clause, where_params = build_where_clause(scope) + model_param: Final = len(where_params) + 1 + sentinel_param: Final = model_param + 1 + limit_param: Final = sentinel_param + 1 + model_clause: Final = ( + f"COALESCE(NULLIF(model_group, ''), model) = ${model_param}" if by_model_group else f"model = ${model_param}" + ) + sql: Final = f""" + SELECT api_key,{_key_spend_select()} + FROM "{PRISMA_TO_PG_TABLE[scope.table]}" + WHERE {where_clause} AND {model_clause} AND api_key <> ${sentinel_param} + GROUP BY api_key + ORDER BY SUM(spend::numeric) DESC, api_key + LIMIT ${limit_param} + """ + return SqlQuery(sql=sql, params=(*where_params, model_group, PTU_SENTINEL_API_KEY, limit)) + + +def build_cache_leakage_keys_sql(scope: DailyActivityScope, *, limit: int) -> SqlQuery: + _bounded_limit(limit) + where_clause, where_params = build_where_clause(scope) + sentinel_param: Final = len(where_params) + 1 + limit_param: Final = sentinel_param + 1 + sql: Final = f""" + SELECT api_key,{_key_spend_select()} + FROM "{PRISMA_TO_PG_TABLE[scope.table]}" + WHERE {where_clause} AND api_key <> ${sentinel_param} + GROUP BY api_key + HAVING SUM(prompt_tokens) - SUM(cache_read_input_tokens) > 0 + ORDER BY SUM(prompt_tokens) - SUM(cache_read_input_tokens) DESC, api_key + LIMIT ${limit_param} + """ + return SqlQuery(sql=sql, params=(*where_params, PTU_SENTINEL_API_KEY, limit)) + + +def build_export_sql( + scope: DailyActivityScope, *, export_type: ExportType, after: ExportCursor | None, batch_size: int +) -> SqlQuery: + _bounded_limit(batch_size) + where_clause, where_params = build_where_clause(scope) + group_key, output_key, user_fields, type_joins = _export_grouping(export_type) + grouping_keys: Final = ( + f"scoped.date, COALESCE(scoped.\"{scope.entity_id_field}\", '')", + *((group_key,) if export_type is not ExportType.DAILY else ()), + ) + entity_joins: Final = ( + ('LEFT JOIN "LiteLLM_TeamTable" tt ON tt.team_id = scoped.team_id',) + if scope.table is DailyActivityTable.TEAM + else ('LEFT JOIN "LiteLLM_OrganizationTable" ot ON ot.organization_id = scoped.organization_id',) + if scope.table is DailyActivityTable.ORGANIZATION + else () + ) + joins: Final = (*type_joins, *entity_joins) + alias_expression: Final = ( + "MAX(tt.team_alias)" + if scope.table is DailyActivityTable.TEAM + else "MAX(ot.organization_alias)" + if scope.table is DailyActivityTable.ORGANIZATION + else "NULL::text" + ) + parameter_indexes: Final = count(len(where_params) + 1) + sentinel_param: Final = next(parameter_indexes) if export_type is not ExportType.DAILY else None + cursor_indexes: Final = tuple(islice(parameter_indexes, 3)) if after is not None else () + limit_param: Final = next(parameter_indexes) + cursor_clause, cursor_params = _export_cursor_clause( + scope, after=after, cursor_indexes=cursor_indexes, group_key=group_key + ) + sentinel_clause: Final = f" AND api_key <> ${sentinel_param}" if sentinel_param is not None else "" + table: Final = PRISMA_TO_PG_TABLE[scope.table] + flat_cost: Final = _ptu_flat_cost_select(scope.table, aggregate=False) + sql: Final = f""" + WITH scoped AS ( + SELECT * FROM "{table}" + WHERE {where_clause}{sentinel_clause} + ) + SELECT + scoped.date, + COALESCE(scoped."{scope.entity_id_field}", '') AS entity_id, + {alias_expression} AS entity_alias, + {output_key} AS api_key, + {user_fields}, + {"NULLIF(COALESCE(scoped.model, ''), '')" if export_type is ExportType.DAILY_WITH_MODELS else "NULL::text"} AS model, + COALESCE(SUM(scoped.spend), 0)::float AS spend, + {flat_cost} AS flat_cost, + COALESCE(SUM(scoped.prompt_tokens), 0)::bigint AS prompt_tokens, + COALESCE(SUM(scoped.completion_tokens), 0)::bigint AS completion_tokens, + COALESCE(SUM(scoped.api_requests), 0)::bigint AS api_requests, + COALESCE(SUM(scoped.successful_requests), 0)::bigint AS successful_requests, + COALESCE(SUM(scoped.failed_requests), 0)::bigint AS failed_requests, + COALESCE(SUM(scoped.cache_read_input_tokens), 0)::bigint AS cache_read_input_tokens, + COALESCE(SUM(scoped.cache_creation_input_tokens), 0)::bigint AS cache_creation_input_tokens + FROM scoped + {" ".join(joins)} + WHERE TRUE{cursor_clause} + GROUP BY {", ".join(grouping_keys)} + ORDER BY {", ".join(grouping_keys)} + LIMIT ${limit_param} + """ + return SqlQuery( + sql=sql, + params=( + *where_params, + *((PTU_SENTINEL_API_KEY,) if export_type is not ExportType.DAILY else ()), + *cursor_params, + batch_size, + ), + ) + + +def _export_grouping(export_type: ExportType) -> tuple[str, str, str, tuple[str, ...]]: + if export_type is ExportType.DAILY: + return ( + "''", + "NULL::text", + "NULL::text AS key_alias, NULL::text AS user_id, NULL::text AS user_email", + (), + ) + if export_type is ExportType.DAILY_WITH_KEYS: + return ( + "scoped.api_key", + "NULLIF(scoped.api_key, '')", + "MAX(COALESCE(vt.key_alias, dvt.key_alias)) AS key_alias, " + "MAX(COALESCE(vt.user_id, dvt.user_id)) AS user_id, MAX(u.user_email) AS user_email", + ( + 'LEFT JOIN "LiteLLM_VerificationToken" vt ON vt.token = scoped.api_key', + """LEFT JOIN LATERAL ( + SELECT key_alias, user_id + FROM "LiteLLM_DeletedVerificationToken" + WHERE token = scoped.api_key + ORDER BY deleted_at DESC + LIMIT 1 + ) dvt ON vt.token IS NULL""", + 'LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = COALESCE(vt.user_id, dvt.user_id)', + ), + ) + if export_type is ExportType.DAILY_WITH_MODELS: + return ( + "COALESCE(scoped.model, '')", + "NULL::text", + "NULL::text AS key_alias, NULL::text AS user_id, NULL::text AS user_email", + (), + ) + if export_type is ExportType.DAILY_WITH_USERS: + return ( + "COALESCE(vt.user_id, dvt.user_id, '')", + "NULL::text", + "NULL::text AS key_alias, MAX(COALESCE(vt.user_id, dvt.user_id)) AS user_id, " + "MAX(u.user_email) AS user_email", + ( + 'LEFT JOIN "LiteLLM_VerificationToken" vt ON vt.token = scoped.api_key', + """LEFT JOIN LATERAL ( + SELECT key_alias, user_id + FROM "LiteLLM_DeletedVerificationToken" + WHERE token = scoped.api_key + ORDER BY deleted_at DESC + LIMIT 1 + ) dvt ON vt.token IS NULL""", + 'LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = COALESCE(vt.user_id, dvt.user_id)', + ), + ) + assert_never(export_type) + raise AssertionError("unreachable") + + +def _export_cursor_clause( + scope: DailyActivityScope, + *, + after: ExportCursor | None, + cursor_indexes: tuple[int, ...], + group_key: str, +) -> tuple[str, tuple[object, ...]]: + if after is None: + return "", () + first_cursor_index: Final = cursor_indexes[0] + clause: Final = ( + f""" AND (scoped.date, COALESCE(scoped."{scope.entity_id_field}", ''), {group_key}) """ + f"> (${first_cursor_index}, ${cursor_indexes[1]}, ${cursor_indexes[2]})" + ) + return clause, (after.date, after.entity_id, after.group_key) diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 28488ba7de6..5f997945ec2 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -101,6 +101,22 @@ class DailySpendMetadata(BaseModel): page: int = Field(default=1) total_pages: int = Field(default=1) has_more: bool = Field(default=False) + api_key_limit: int | None = Field( + default=None, + description="When set, api_keys and every api_key_breakdown list at most this many keys, " + "ranked by spend. Totals and the model, provider, mcp and endpoint rollups still cover every key.", + ) + total_api_keys: int | None = Field( + default=None, + description="Distinct API keys matching the filters. When this exceeds api_key_limit, the per-key " + "lists are truncated to the highest-spend keys.", + ) + entity_total_api_keys: dict[str, int] | None = Field( + default=None, + description="Distinct API keys per entity over the requested range, set when the entity breakdown is " + "included. When an entity's count exceeds api_key_limit, its api_key_breakdown lists only its keys " + "among the top api_key_limit keys overall.", + ) class SpendAnalyticsPaginatedResponse(BaseModel): diff --git a/litellm/types/repositories/__init__.py b/litellm/types/repositories/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/types/repositories/daily_activity.py b/litellm/types/repositories/daily_activity.py new file mode 100644 index 00000000000..df302234398 --- /dev/null +++ b/litellm/types/repositories/daily_activity.py @@ -0,0 +1,192 @@ +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from types import MappingProxyType +from typing import Protocol, TypeAlias + + +class DailyActivityTable(str, Enum): + USER = "litellm_dailyuserspend" + TEAM = "litellm_dailyteamspend" + TAG = "litellm_dailytagspend" + ORGANIZATION = "litellm_dailyorganizationspend" + CUSTOMER = "litellm_dailyenduserspend" + AGENT = "litellm_dailyagentspend" + + +_ENTITY_FIELDS: Mapping[DailyActivityTable, frozenset[str]] = MappingProxyType( + { + DailyActivityTable.USER: frozenset(("user_id",)), + DailyActivityTable.TEAM: frozenset(("team_id",)), + DailyActivityTable.TAG: frozenset(("tag",)), + DailyActivityTable.ORGANIZATION: frozenset(("organization_id",)), + DailyActivityTable.CUSTOMER: frozenset(("end_user_id",)), + DailyActivityTable.AGENT: frozenset(("agent_id",)), + } +) + + +@dataclass(frozen=True, slots=True) +class DailyActivityScope: + table: DailyActivityTable + entity_id_field: str + entity_ids: tuple[str, ...] | None + exclude_entity_ids: tuple[str, ...] + api_keys: tuple[str, ...] | None + start_date: str + end_date: str + model: str | None + timezone_offset_minutes: int | None + include_current_utc_day: bool = False + + def __post_init__(self) -> None: + if self.entity_id_field not in _ENTITY_FIELDS[self.table]: + raise ValueError(f"Invalid entity_id_field {self.entity_id_field!r} for {self.table.value}") + + +@dataclass(frozen=True, slots=True) +class KeySpendRow: + api_key: str + spend: float + prompt_tokens: int + completion_tokens: int + total_tokens: int + api_requests: int + successful_requests: int + failed_requests: int + cache_read_input_tokens: int + cache_creation_input_tokens: int + + +@dataclass(frozen=True, slots=True) +class KeyPage: + rows: tuple[KeySpendRow, ...] + total_api_keys: int + + +@dataclass(frozen=True, slots=True) +class KeyMetadataRow: + api_key: str + key_alias: str | None + team_id: str | None + user_id: str | None + user_email: str | None + key_exists: bool + tags: tuple[str, ...] + + +class ExportType(str, Enum): + DAILY = "daily" + DAILY_WITH_KEYS = "daily_with_keys" + DAILY_WITH_MODELS = "daily_with_models" + DAILY_WITH_USERS = "daily_with_users" + + +@dataclass(frozen=True, slots=True) +class ExportRow: + date: str + entity_id: str + entity_alias: str | None + api_key: str | None + key_alias: str | None + user_id: str | None + user_email: str | None + model: str | None + spend: float + flat_cost: float + prompt_tokens: int + completion_tokens: int + api_requests: int + successful_requests: int + failed_requests: int + cache_read_input_tokens: int + cache_creation_input_tokens: int + + +@dataclass(frozen=True, slots=True) +class RollupMetricsRow: + date: str | None + api_key: str | None + spend: float | None + ptu_flat_cost: float | None = field(default=None, kw_only=True) + prompt_tokens: int | None + completion_tokens: int | None + cache_read_input_tokens: int | None + cache_creation_input_tokens: int | None + compression_saved_tokens: int | None + compression_savings_spend: float | None + prompt_caching_savings_spend: float | None + gateway_injected_caching_savings_spend: float | None + autorouter_savings_spend: float | None + api_requests: int | None + successful_requests: int | None + failed_requests: int | None + total_response_time_ms: int | None + timed_requests: int | None + + +@dataclass(frozen=True, slots=True) +class GroupingSetsRow(RollupMetricsRow): + model: str | None + model_group: str | None + custom_llm_provider: str | None + mcp_namespaced_tool_name: str | None + endpoint: str | None + group_level: int + distinct_api_keys: int | None + + +@dataclass(frozen=True, slots=True) +class EntityRollupRow(RollupMetricsRow): + entity_id: str | None + api_key_rolled: int + distinct_api_keys: int | None + + +@dataclass(frozen=True, slots=True) +class AggregatedRows: + grouping_rows: tuple[GroupingSetsRow, ...] + entity_rows: tuple[EntityRollupRow, ...] | None + distinct_api_keys: int + + +SpendLogsWindow: TypeAlias = tuple[datetime, datetime] + + +class DailyActivityProxyReads(Protocol): + async def recover_key_metadata( + self, resolved: Mapping[str, KeyMetadataRow], api_keys: frozenset[str], window: SpendLogsWindow | None + ) -> Mapping[str, KeyMetadataRow]: ... + + +class DailyActivityRow(Protocol): + id: str + date: str + api_key: str + model: str | None + model_group: str | None + custom_llm_provider: str | None + mcp_namespaced_tool_name: str | None + endpoint: str | None + prompt_tokens: int + completion_tokens: int + cache_read_input_tokens: int + cache_creation_input_tokens: int + compression_saved_tokens: int + compression_savings_spend: float + prompt_caching_savings_spend: float + gateway_injected_caching_savings_spend: float + autorouter_savings_spend: float + spend: float + api_requests: int + successful_requests: int + failed_requests: int + total_response_time_ms: int + timed_requests: int + + +@dataclass(frozen=True, slots=True) +class DailyRowsPage: + total_count: int + rows: tuple[DailyActivityRow, ...] diff --git a/tests/code_coverage_tests/unbounded_in_baseline.txt b/tests/code_coverage_tests/unbounded_in_baseline.txt index 17e8d32dde0..9acc92e2afd 100644 --- a/tests/code_coverage_tests/unbounded_in_baseline.txt +++ b/tests/code_coverage_tests/unbounded_in_baseline.txt @@ -39,14 +39,6 @@ litellm/proxy/management_endpoints/auto_router_endpoints.py start_shadow_eval pr litellm/proxy/management_endpoints/auto_router_endpoints.py start_shadow_eval prisma token.in `list(data.api_key_ids)` 0 litellm/proxy/management_endpoints/auto_router_endpoints.py start_shadow_eval prisma user_id.in `list(data.user_ids)` 0 litellm/proxy/management_endpoints/budget_management_endpoints.py info_budget prisma budget_id.in `data.budgets` 0 -litellm/proxy/management_endpoints/common_daily_activity.py _build_aggregated_where_clause raw-sql api_key.IN `IN ({placeholders})` 0 -litellm/proxy/management_endpoints/common_daily_activity.py _build_aggregated_where_clause raw-sql {entity_id_field}.IN `IN ({placeholders})` 0 -litellm/proxy/management_endpoints/common_daily_activity.py _build_aggregated_where_clause raw-sql {entity_id_field}.IN `IN ({placeholders})` 1 -litellm/proxy/management_endpoints/common_daily_activity.py _build_where_conditions prisma [entity_id_field].in `entity_id` 0 -litellm/proxy/management_endpoints/common_daily_activity.py _build_where_conditions prisma api_key.in `api_key` 0 -litellm/proxy/management_endpoints/common_daily_activity.py _build_where_conditions prisma not.in `exclude_entity_ids` 0 -litellm/proxy/management_endpoints/common_daily_activity.py get_api_key_metadata prisma token.in `list(api_keys)` 0 -litellm/proxy/management_endpoints/common_daily_activity.py get_api_key_metadata prisma token.in `list(missing_keys)` 0 litellm/proxy/management_endpoints/common_utils.py _team_admin_can_invite_user prisma team_id.in `admin_user_obj.teams` 0 litellm/proxy/management_endpoints/common_utils.py _user_has_admin_privileges prisma team_id.in `user_obj.teams` 0 litellm/proxy/management_endpoints/customer_endpoints.py delete_end_user prisma user_id.in `data.user_ids` 0 @@ -149,4 +141,7 @@ litellm/proxy/utils.py PrismaClient.get_data prisma budget_id.in `budget_id_list litellm/proxy/utils.py PrismaClient.get_data prisma team_id.in `team_id_list` 0 litellm/proxy/utils.py PrismaClient.get_data prisma user_id.in `user_id_list` 0 litellm/proxy/utils.py prefetch_config_params prisma param_name.in `param_names` 0 +litellm/repositories/daily_activity_repository.py DailyActivityRepository.daily_rows prisma ?.in `list(scope.entity_ids)` 0 +litellm/repositories/daily_activity_repository.py DailyActivityRepository.daily_rows prisma api_key.in `list(scope.api_keys)` 0 +litellm/repositories/daily_activity_repository.py DailyActivityRepository.daily_rows prisma not.in `list(scope.exclude_entity_ids)` 0 litellm/router_utils/auto_router_model_naming.py raw-sql classifier_type.IN `IN ({_LLM_CLASSIFIER_TYPES_SQL})` 0 diff --git a/tests/integration/spend/_daily_activity_fixtures.py b/tests/integration/spend/_daily_activity_fixtures.py new file mode 100644 index 00000000000..d6e819edca6 --- /dev/null +++ b/tests/integration/spend/_daily_activity_fixtures.py @@ -0,0 +1,319 @@ +from itertools import product +from typing import Final + +import psycopg +from psycopg import sql + +_TABLE_NAMES: Final = ( + "LiteLLM_DailyUserSpend", + "LiteLLM_DailyTeamSpend", + "LiteLLM_VerificationToken", + "LiteLLM_DeletedVerificationToken", + "LiteLLM_UserTable", + "LiteLLM_TeamTable", +) + +_TAG_KEY_MEMBERSHIPS: Final = ( + ("tag-a", "entity-key-0"), + ("tag-a", "entity-key-1"), + ("tag-a", "entity-key-2"), + ("tag-a", "entity-key-3"), + ("tag-a", "entity-key-4"), + ("tag-b", "entity-key-0"), + ("tag-b", "entity-key-1"), + ("tag-b", "entity-key-5"), + ("tag-c", "entity-key-2"), + ("tag-c", "entity-key-3"), + ("tag-c", "entity-key-6"), + ("tag-c", "entity-key-7"), + ("tag-d", "entity-key-4"), + ("tag-d", "entity-key-5"), + ("tag-d", "entity-key-6"), + ("tag-d", "entity-key-7"), +) +_TAG_ACTIVITY_DATES: Final = ("2026-06-01", "2026-06-02") + + +def seed_daily_activity_fixture(connection: psycopg.Connection, *, schema: str, ptu_sentinel_api_key: str) -> None: + daily_user_table: Final = sql.Identifier(schema, "LiteLLM_DailyUserSpend") + daily_team_table: Final = sql.Identifier(schema, "LiteLLM_DailyTeamSpend") + verification_token_table: Final = sql.Identifier(schema, "LiteLLM_VerificationToken") + deleted_token_table: Final = sql.Identifier(schema, "LiteLLM_DeletedVerificationToken") + user_table: Final = sql.Identifier(schema, "LiteLLM_UserTable") + team_table: Final = sql.Identifier(schema, "LiteLLM_TeamTable") + keys: Final = ( + ("key-a", "model-popular", 100.0, 2, 1), + ("key-b", "model-popular", 90.0, 3, 1), + ("key-c", "model-popular", 80.0, 4, 1), + ("key-target", "model-target", 1.0, 5, 2), + ("key-cache", "model-cache", 2.0, 1000, 1), + ) + user_rows: Final = tuple( + ( + f"user-row-{index}", + "user-1", + "2026-06-01", + api_key, + model, + "", + "provider-a", + None, + "/v1/chat/completions", + prompt_tokens, + 2, + cache_read_tokens, + 0, + spend, + 1, + 1, + 0, + "2026-06-01 12:00:00", + ) + for index, (api_key, model, spend, prompt_tokens, cache_read_tokens) in enumerate(keys) + ) + team_rows: Final = tuple( + ( + f"team-row-{index}", + "team-1", + "2026-06-01", + api_key, + model, + "", + "provider-a", + None, + "/v1/chat/completions", + prompt_tokens, + 2, + cache_read_tokens, + 0, + spend, + 1, + 1, + 0, + 0.0, + "2026-06-01 12:00:00", + ) + for index, (api_key, model, spend, prompt_tokens, cache_read_tokens) in enumerate(keys) + ) + sentinel_user_row: Final = ( + "user-row-ptu", + "user-1", + "2026-06-01", + ptu_sentinel_api_key, + "model-ptu", + "", + "provider-a", + None, + "/v1/chat/completions", + 0, + 0, + 0, + 0, + 1000.0, + 0, + 0, + 0, + "2026-06-01 12:00:00", + ) + sentinel_team_row: Final = ( + "team-row-ptu", + "team-1", + "2026-06-01", + ptu_sentinel_api_key, + "model-ptu", + "", + "provider-a", + None, + "/v1/chat/completions", + 0, + 0, + 0, + 0, + 1000.0, + 0, + 0, + 0, + 42.0, + "2026-06-01 12:00:00", + ) + with connection.cursor() as cursor: + for table_name in _TABLE_NAMES: + cursor.execute( + sql.SQL("CREATE TABLE {} (LIKE {} INCLUDING DEFAULTS INCLUDING CONSTRAINTS)").format( + sql.Identifier(schema, table_name), + sql.Identifier(table_name), + ) + ) + cursor.executemany( + sql.SQL(""" + INSERT INTO {} + (id, user_id, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, completion_tokens, + cache_read_input_tokens, cache_creation_input_tokens, spend, api_requests, + successful_requests, failed_requests, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """).format(daily_user_table), + (*user_rows, sentinel_user_row), + ) + cursor.executemany( + sql.SQL(""" + INSERT INTO {} + (id, team_id, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, completion_tokens, + cache_read_input_tokens, cache_creation_input_tokens, spend, api_requests, + successful_requests, failed_requests, ptu_flat_cost, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """).format(daily_team_table), + (*team_rows, sentinel_team_row), + ) + cursor.executemany( + sql.SQL( + "INSERT INTO {} (token, key_alias, team_id, user_id, metadata, models) VALUES (%s, %s, %s, %s, %s, %s)" + ).format(verification_token_table), + ( + ("key-a", "alias-a", "team-1", "user-1", '{"tags": ["blue", "gold"]}', []), + ("key-b", "alias-b", "team-1", "user-1", '{"tags": []}', []), + ("key-c", "alias-c", "team-1", "user-1", '{"tags": []}', []), + ("key-cache", "alias-cache", "team-1", "user-1", '{"tags": []}', []), + ), + ) + cursor.executemany( + sql.SQL(""" + INSERT INTO {} + (id, token, key_alias, team_id, user_id, metadata, models, deleted_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """).format(deleted_token_table), + ( + ("deleted-old", "key-target", "older-target", "team-1", "user-1", '{"tags": []}', [], "2026-06-01"), + ( + "deleted-new", + "key-target", + "deleted-target", + "team-1", + "user-1", + '{"tags": ["archived"]}', + [], + "2026-06-02", + ), + ), + ) + cursor.execute( + sql.SQL("INSERT INTO {} (user_id, user_email, models) VALUES (%s, %s, %s)").format(user_table), + ("user-1", "user@example.com", []), + ) + cursor.execute( + sql.SQL("INSERT INTO {} (team_id, team_alias, admins, members, models) VALUES (%s, %s, %s, %s, %s)").format( + team_table + ), + ("team-1", "Usage Team", [], [], []), + ) + connection.commit() + + +def seed_daily_tag_activity_fixture(connection: psycopg.Connection, *, schema: str) -> None: + tag_table: Final = sql.Identifier(schema, "LiteLLM_DailyTagSpend") + rows: Final = tuple( + ( + f"tag-rollup-{row_index}", + tag, + date, + api_key, + "entity-rollup-model", + "", + "provider-a", + None, + "/v1/chat/completions", + row_index + 1, + float(row_index + 1), + row_index % 5 + 1, + f"{date} 12:00:00", + ) + for row_index, (date, (tag, api_key)) in enumerate(product(_TAG_ACTIVITY_DATES, _TAG_KEY_MEMBERSHIPS)) + ) + with connection.cursor() as cursor: + cursor.execute( + sql.SQL("CREATE TABLE {} (LIKE {} INCLUDING DEFAULTS INCLUDING CONSTRAINTS)").format( + tag_table, + sql.Identifier("LiteLLM_DailyTagSpend"), + ) + ) + cursor.executemany( + sql.SQL(""" + INSERT INTO {} + (id, tag, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, spend, api_requests, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """).format(tag_table), + rows, + ) + connection.commit() + + +def seed_daily_tag_float_tie_fixture(connection: psycopg.Connection, *, schema: str) -> None: + tag_table: Final = sql.Identifier(schema, "LiteLLM_DailyTagSpend") + key_spends: Final = ( + ("key-z", 0.1), + ("key-z", 0.2), + ("key-z", 0.3), + ("key-a", 0.3), + ("key-a", 0.2), + ("key-a", 0.1), + ) + rows: Final = ( + ( + f"float-tie-{row_index}", + "tag-float-tie", + "2026-06-01", + api_key, + "float-tie-model", + "", + "provider-a", + None, + "/v1/chat/completions", + 1, + spend, + 1, + "2026-06-01 12:00:00", + ) + for row_index, (api_key, spend) in enumerate(key_spends, start=1) + ) + with connection.cursor() as cursor: + cursor.execute( + sql.SQL("CREATE TABLE {} (LIKE {} INCLUDING DEFAULTS INCLUDING CONSTRAINTS)").format( + tag_table, + sql.Identifier("LiteLLM_DailyTagSpend"), + ) + ) + cursor.executemany( + sql.SQL(""" + INSERT INTO {} + (id, tag, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, spend, api_requests, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """).format(tag_table), + rows, + ) + connection.commit() + + +def seed_daily_team_unassigned_fixture( + connection: psycopg.Connection, *, schema: str, ptu_sentinel_api_key: str +) -> None: + team_table: Final = sql.Identifier(schema, "LiteLLM_DailyTeamSpend") + rows: Final = ( + ("unassigned-null", None, "key-unassigned-null", 3.0, 0.0), + ("unassigned-empty", "", "key-unassigned-empty", 7.0, 0.0), + ("unassigned-ptu", None, ptu_sentinel_api_key, 13.0, 13.0), + ) + with connection.cursor() as cursor: + cursor.executemany( + sql.SQL(""" + INSERT INTO {} + (id, team_id, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, spend, api_requests, ptu_flat_cost, updated_at) + VALUES (%s, %s, '2026-06-03', %s, 'model-a', '', 'provider-a', NULL, '/v1/chat/completions', + 1, %s, 1, %s, '2026-06-03 12:00:00') + """).format(team_table), + rows, + ) + connection.commit() diff --git a/tests/integration/spend/fixtures/daily_activity_team.json b/tests/integration/spend/fixtures/daily_activity_team.json new file mode 100644 index 00000000000..d833a695ede --- /dev/null +++ b/tests/integration/spend/fixtures/daily_activity_team.json @@ -0,0 +1 @@ +{"results":[{"date":"2026-06-01","metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"breakdown":{"mcp_servers":{},"models":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"model_groups":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"providers":{"provider-a":{"metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"endpoints":{"/v1/chat/completions":{"metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"api_keys":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}},"entities":{"team-1":{"metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{"team_alias":"Usage Team"},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}}}}}],"metadata":{"total_spend":1273.0,"total_flat_cost":0.0,"total_prompt_tokens":1014,"total_completion_tokens":10,"total_tokens":1024,"total_api_requests":5,"total_successful_requests":5,"total_failed_requests":0,"total_cache_read_input_tokens":6,"total_cache_creation_input_tokens":0,"total_compression_saved_tokens":0,"total_compression_savings_spend":0.0,"total_prompt_caching_savings_spend":0.0,"total_gateway_injected_caching_savings_spend":0.0,"total_autorouter_savings_spend":0.0,"total_response_time_ms":0,"total_timed_requests":0,"page":1,"total_pages":1,"has_more":false,"api_key_limit":100,"total_api_keys":5,"entity_total_api_keys":{"team-1":5}}} diff --git a/tests/integration/spend/fixtures/daily_activity_user.json b/tests/integration/spend/fixtures/daily_activity_user.json new file mode 100644 index 00000000000..36701c20d9b --- /dev/null +++ b/tests/integration/spend/fixtures/daily_activity_user.json @@ -0,0 +1 @@ +{"results":[{"date":"2026-06-01","metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"breakdown":{"mcp_servers":{},"models":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"model_groups":{"model-ptu":{"metrics":{"spend":1000.0,"flat_cost":0.0,"prompt_tokens":0,"completion_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":0,"successful_requests":0,"failed_requests":0,"api_requests":0,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{}},"model-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}},"model-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}},"model-popular":{"metrics":{"spend":270.0,"flat_cost":0.0,"prompt_tokens":9,"completion_tokens":6,"cache_read_input_tokens":3,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":15,"successful_requests":3,"failed_requests":0,"api_requests":3,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"providers":{"provider-a":{"metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"endpoints":{"/v1/chat/completions":{"metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}}}}},"api_keys":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}},"entities":{"user-1":{"metrics":{"spend":1273.0,"flat_cost":0.0,"prompt_tokens":1014,"completion_tokens":10,"cache_read_input_tokens":6,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1024,"successful_requests":5,"failed_requests":0,"api_requests":5,"total_response_time_ms":0,"timed_requests":0},"metadata":{},"api_key_breakdown":{"key-a":{"metrics":{"spend":100.0,"flat_cost":0.0,"prompt_tokens":2,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":4,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-a","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-b":{"metrics":{"spend":90.0,"flat_cost":0.0,"prompt_tokens":3,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":5,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-b","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-c":{"metrics":{"spend":80.0,"flat_cost":0.0,"prompt_tokens":4,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":6,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-c","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-cache":{"metrics":{"spend":2.0,"flat_cost":0.0,"prompt_tokens":1000,"completion_tokens":2,"cache_read_input_tokens":1,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":1002,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"alias-cache","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":true}},"key-target":{"metrics":{"spend":1.0,"flat_cost":0.0,"prompt_tokens":5,"completion_tokens":2,"cache_read_input_tokens":2,"cache_creation_input_tokens":0,"compression_saved_tokens":0,"compression_savings_spend":0.0,"prompt_caching_savings_spend":0.0,"gateway_injected_caching_savings_spend":0.0,"autorouter_savings_spend":0.0,"total_tokens":7,"successful_requests":1,"failed_requests":0,"api_requests":1,"total_response_time_ms":0,"timed_requests":0},"metadata":{"key_alias":"deleted-target","team_id":"team-1","user_id":"user-1","user_email":"user@example.com","key_exists":false}}}}}}}],"metadata":{"total_spend":1273.0,"total_flat_cost":0.0,"total_prompt_tokens":1014,"total_completion_tokens":10,"total_tokens":1024,"total_api_requests":5,"total_successful_requests":5,"total_failed_requests":0,"total_cache_read_input_tokens":6,"total_cache_creation_input_tokens":0,"total_compression_saved_tokens":0,"total_compression_savings_spend":0.0,"total_prompt_caching_savings_spend":0.0,"total_gateway_injected_caching_savings_spend":0.0,"total_autorouter_savings_spend":0.0,"total_response_time_ms":0,"total_timed_requests":0,"page":1,"total_pages":1,"has_more":false,"api_key_limit":100,"total_api_keys":5,"entity_total_api_keys":{"user-1":5}}} diff --git a/tests/integration/spend/test_daily_activity_aggregated_breakdowns.py b/tests/integration/spend/test_daily_activity_aggregated_breakdowns.py index 56c936a716f..33baf9d0e2e 100644 --- a/tests/integration/spend/test_daily_activity_aggregated_breakdowns.py +++ b/tests/integration/spend/test_daily_activity_aggregated_breakdowns.py @@ -6,7 +6,7 @@ from typing import Final import pytest from pydantic import JsonValue, TypeAdapter -from litellm.constants import PTU_SENTINEL_API_KEY +from litellm.constants import PTU_SENTINEL_API_KEY, USAGE_TOP_API_KEYS_DEFAULT from tests.integration._support.client import Gateway, object_value from tests.integration._support.database import write_rows @@ -43,57 +43,86 @@ def _row_id() -> str: return f"agg-{uuid.uuid4().hex}" +def _ranked_key_rows(day: str, count: int) -> list[tuple[object, ...]]: + return [ + ( + _row_id(), + f"user-{i:03d}", + day, + f"key-{i:03d}", + "gpt-5", + "", + "openai", + None, + "/v1/chat/completions", + 10, + 6.0 if i == 4 else float(i + 1), + 1, + 1, + ) + for i in range(count) + ] + + @pytest.mark.asyncio -async def test_get_daily_activity_aggregated_returns_every_api_key(gateway: Gateway) -> None: +async def test_get_daily_activity_aggregated_bounds_api_key_rollups(gateway: Gateway) -> None: + """key-004 and key-005 tie on spend exactly at the default api_key_limit cutoff; the api_key + tiebreaker keeps key-004 and drops key-005. The PTU sentinel outspends every key but takes no + slot. Dropped keys and the sentinel still count toward the totals and the model rollup.""" + key_count: Final = USAGE_TOP_API_KEYS_DEFAULT + 5 day: Final = _unique_day() _seed( day, [ - *[ - ( - _row_id(), - f"user-{i:03d}", - day, - f"key-{i:03d}", - "gpt-5", - "", - "openai", - None, - "/v1/chat/completions", - 10, - 6.0 if i == 4 else float(i + 1), - 1, - 1, - ) - for i in range(105) - ], + *_ranked_key_rows(day, key_count), (_row_id(), None, day, PTU_SENTINEL_API_KEY, "gpt-5", "", "azure", None, None, 0, 1000.0, 0, 0), ], ) + key_spend: Final = sum(6.0 if i == 4 else float(i + 1) for i in range(key_count)) try: body: Final = _activity(gateway, day) metadata: Final = object_value(body["metadata"]) - assert metadata["total_spend"] == pytest.approx(6566.0) - assert metadata["total_api_requests"] == 105 + assert metadata["total_spend"] == pytest.approx(key_spend + 1000.0) + assert metadata["total_api_requests"] == key_count + assert metadata["total_api_keys"] == key_count + assert metadata["api_key_limit"] == USAGE_TOP_API_KEYS_DEFAULT results: Final = _RESULTS.validate_python(body["results"]) assert len(results) == 1 result_day: Final = object_value(results[0]) - assert object_value(result_day["metrics"])["spend"] == pytest.approx(6566.0) + assert object_value(result_day["metrics"])["spend"] == pytest.approx(key_spend + 1000.0) breakdown: Final = object_value(result_day["breakdown"]) - expected_api_keys: Final = {f"key-{i:03d}" for i in range(105)} + expected_top: Final = {f"key-{i:03d}" for i in range(6, key_count)} | {"key-004"} api_keys: Final = object_value(breakdown["api_keys"]) - assert set(api_keys) == expected_api_keys + assert set(api_keys) == expected_top + assert object_value(object_value(api_keys["key-004"])["metrics"])["spend"] == 6.0 assert PTU_SENTINEL_API_KEY not in api_keys models: Final = object_value(breakdown["models"]) gpt5: Final = object_value(models["gpt-5"]) - assert object_value(gpt5["metrics"])["spend"] == pytest.approx(6566.0) - assert set(object_value(gpt5["api_key_breakdown"])) == expected_api_keys + assert object_value(gpt5["metrics"])["spend"] == pytest.approx(key_spend + 1000.0) + assert set(object_value(gpt5["api_key_breakdown"])) == expected_top providers: Final = object_value(breakdown["providers"]) openai: Final = object_value(providers["openai"]) - assert object_value(openai["metrics"])["spend"] == pytest.approx(5566.0) - assert set(object_value(openai["api_key_breakdown"])) == expected_api_keys + assert object_value(openai["metrics"])["spend"] == pytest.approx(key_spend) + assert set(object_value(openai["api_key_breakdown"])) == expected_top endpoints: Final = object_value(breakdown["endpoints"]) - assert object_value(object_value(endpoints["/v1/chat/completions"])["metrics"])["api_requests"] == 105 + assert object_value(object_value(endpoints["/v1/chat/completions"])["metrics"])["api_requests"] == key_count + finally: + _clean(day) + + +@pytest.mark.asyncio +async def test_get_daily_activity_aggregated_reports_exact_limit_key_count_as_complete(gateway: Gateway) -> None: + """With exactly USAGE_TOP_API_KEYS_DEFAULT keys nothing is dropped and total_api_keys equals the limit.""" + day: Final = _unique_day() + _seed(day, _ranked_key_rows(day, USAGE_TOP_API_KEYS_DEFAULT)) + try: + body: Final = _activity(gateway, day) + metadata: Final = object_value(body["metadata"]) + assert metadata["total_api_keys"] == USAGE_TOP_API_KEYS_DEFAULT + assert metadata["api_key_limit"] == USAGE_TOP_API_KEYS_DEFAULT + results: Final = _RESULTS.validate_python(body["results"]) + api_keys: Final = object_value(object_value(object_value(results[0])["breakdown"])["api_keys"]) + assert set(api_keys) == {f"key-{i:03d}" for i in range(USAGE_TOP_API_KEYS_DEFAULT)} finally: _clean(day) @@ -126,7 +155,9 @@ async def test_get_daily_activity_aggregated_explicit_api_key_filter_scopes_resu ) try: body: Final = _activity(gateway, day, api_key="key-1") - assert object_value(body["metadata"])["total_spend"] == 2.0 + metadata: Final = object_value(body["metadata"]) + assert metadata["total_spend"] == 2.0 + assert metadata["total_api_keys"] == 1 results: Final = _RESULTS.validate_python(body["results"]) assert len(results) == 1 breakdown: Final = object_value(object_value(results[0])["breakdown"]) diff --git a/tests/integration/spend/test_daily_activity_repository.py b/tests/integration/spend/test_daily_activity_repository.py new file mode 100644 index 00000000000..c347e18bf64 --- /dev/null +++ b/tests/integration/spend/test_daily_activity_repository.py @@ -0,0 +1,624 @@ +import os +import uuid +from collections.abc import AsyncIterator, Mapping +from contextlib import asynccontextmanager +from dataclasses import dataclass +from math import isclose +from pathlib import Path +from types import MappingProxyType +from typing import Final, cast +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import psycopg +import pytest +from integration.spend._daily_activity_fixtures import ( + seed_daily_activity_fixture, + seed_daily_tag_activity_fixture, + seed_daily_tag_float_tie_fixture, + seed_daily_team_unassigned_fixture, +) +from prisma import Prisma +from psycopg import sql +from pydantic import TypeAdapter + +from litellm import constants +from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity_aggregated +from litellm.repositories.chunked_in import find_many_in +from litellm.repositories.daily_activity_repository import DailyActivityDatabase, DailyActivityRepository +from litellm.types.repositories.daily_activity import ( + DailyActivityProxyReads, + DailyActivityScope, + DailyActivityTable, + ExportType, + KeyMetadataRow, + SpendLogsWindow, +) + + +@dataclass(frozen=True, slots=True) +class _TagRollupMetrics: + tag: str | None + date: str + spend: float + api_requests: int + prompt_tokens: int + + +@dataclass(frozen=True, slots=True) +class _TagApiKeyCount: + tag: str | None + distinct_api_keys: int + + +@dataclass(frozen=True, slots=True) +class _TagKeyMembershipCount: + api_key: str + tag_count: int + + +@dataclass(frozen=True, slots=True) +class _TagFloatSpend: + api_key: str + spend: float + + +@dataclass(frozen=True, slots=True) +class _TagRankedKey: + api_key: str + + +@dataclass(frozen=True, slots=True) +class _TagDistinctKeyCount: + total_api_keys: int + + +_TAG_ROLLUP_METRICS_ADAPTER: Final = TypeAdapter(tuple[_TagRollupMetrics, ...]) +_TAG_API_KEY_COUNT_ADAPTER: Final = TypeAdapter(tuple[_TagApiKeyCount, ...]) +_TAG_KEY_MEMBERSHIP_COUNT_ADAPTER: Final = TypeAdapter(tuple[_TagKeyMembershipCount, ...]) +_TAG_FLOAT_SPEND_ADAPTER: Final = TypeAdapter(tuple[_TagFloatSpend, ...]) +_TAG_RANKED_KEY_ADAPTER: Final = TypeAdapter(tuple[_TagRankedKey, ...]) +_TAG_DISTINCT_KEY_COUNT_ADAPTER: Final = TypeAdapter(tuple[_TagDistinctKeyCount, ...]) + + +def _scoped_url(url: str, schema: str) -> str: + parsed: Final = urlsplit(url) + return urlunsplit(parsed._replace(query=urlencode({**dict(parse_qsl(parsed.query)), "schema": schema}))) + + +@asynccontextmanager +async def _daily_activity_database( + *, + include_tag_activity: bool = False, + include_tag_float_tie_activity: bool = False, + include_team_unassigned_activity: bool = False, +) -> AsyncIterator[Prisma]: + schema: Final = f"integration_{uuid.uuid4().hex}" + url: Final = os.environ["DATABASE_URL"] + with psycopg.connect(url, autocommit=True) as setup: + setup.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))) + try: + with psycopg.connect(url) as connection: + seed_daily_activity_fixture( + connection, + schema=schema, + ptu_sentinel_api_key=constants.PTU_SENTINEL_API_KEY, + ) + if include_tag_activity: + seed_daily_tag_activity_fixture(connection, schema=schema) + if include_tag_float_tie_activity: + seed_daily_tag_float_tie_fixture(connection, schema=schema) + if include_team_unassigned_activity: + seed_daily_team_unassigned_fixture( + connection, schema=schema, ptu_sentinel_api_key=constants.PTU_SENTINEL_API_KEY + ) + database: Final = Prisma(datasource={"url": _scoped_url(url, schema)}) + await database.connect() + try: + yield database + finally: + await database.disconnect() + finally: + setup.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema))) + + +@dataclass(frozen=True, slots=True) +class _PrismaDatabase: + db: Prisma + + +@dataclass(frozen=True, slots=True) +class _ProxyReads(DailyActivityProxyReads): + database: Prisma + + async def recover_key_metadata( + self, resolved: Mapping[str, KeyMetadataRow], api_keys: frozenset[str], window: SpendLogsWindow | None + ) -> Mapping[str, KeyMetadataRow]: + user_ids: Final = frozenset(row.user_id for row in resolved.values() if row.user_id) + user_rows: Final = await find_many_in(self.database.litellm_usertable, "user_id", user_ids) if user_ids else () + user_emails: Final = MappingProxyType({row.user_id: row.user_email for row in user_rows if row.user_email}) + return MappingProxyType( + { + key: KeyMetadataRow( + api_key=row.api_key, + key_alias=row.key_alias, + team_id=row.team_id, + user_id=row.user_id, + user_email=row.user_email or user_emails.get(row.user_id), + key_exists=row.key_exists, + tags=row.tags, + ) + for key, row in resolved.items() + } + ) + + +def _repository(database: Prisma) -> DailyActivityRepository: + client: Final = cast(DailyActivityDatabase, _PrismaDatabase(database)) + return DailyActivityRepository(client, proxy_reads=_ProxyReads(database)) + + +def _scope( + table: DailyActivityTable, + entity_id_field: str, + entity_id: str, + api_keys: tuple[str, ...] | None = None, +) -> DailyActivityScope: + return DailyActivityScope( + table=table, + entity_id_field=entity_id_field, + entity_ids=(entity_id,), + exclude_entity_ids=(), + api_keys=api_keys, + start_date="2026-06-01", + end_date="2026-06-01", + model=None, + timezone_offset_minutes=None, + ) + + +@pytest.mark.asyncio +async def test_repository_queries_and_exports_seeded_daily_activity(monkeypatch: pytest.MonkeyPatch) -> None: + async with _daily_activity_database() as database: + repository: Final = _repository(database) + team_scope: Final = _scope(DailyActivityTable.TEAM, "team_id", "team-1") + monkeypatch.setattr(constants, "USAGE_EXPORT_BATCH_SIZE", 2) + aggregate: Final = await repository.aggregated(team_scope, include_entity_breakdown=True, api_key_limit=3) + totals: Final = tuple(row for row in aggregate.grouping_rows if row.group_level == 127) + assert len(totals) == 1 + assert totals[0].spend == 1273.0 + assert totals[0].ptu_flat_cost == 42.0 + assert aggregate.distinct_api_keys == 5 + grouped_keys: Final = frozenset( + row.api_key for row in aggregate.grouping_rows if row.group_level == 31 and row.api_key + ) + assert grouped_keys == frozenset(("key-a", "key-b", "key-c")) + entity_totals: Final = tuple(row for row in aggregate.entity_rows or () if row.api_key_rolled) + assert len(entity_totals) == 1 + assert entity_totals[0].spend == 1273.0 + assert entity_totals[0].ptu_flat_cost == 42.0 + + targeted_model_keys: Final = await repository.model_top_keys( + team_scope, model_group="model-target", by_model_group=False, limit=3 + ) + assert tuple(row.api_key for row in targeted_model_keys) == ("key-target",) + popular_model_keys: Final = await repository.model_top_keys( + team_scope, model_group="model-popular", by_model_group=False, limit=3 + ) + assert tuple(row.api_key for row in popular_model_keys) == ("key-a", "key-b", "key-c") + assert await repository.search_keys(team_scope, search="target", limit=10) == ("key-target",) + assert await repository.search_keys(team_scope, search="deleted-target", limit=20) == ("key-target",) + leakage_keys: Final = await repository.cache_leakage_keys(team_scope, limit=2) + assert tuple(row.api_key for row in leakage_keys) == ("key-cache", "key-c") + + exports: Final = tuple( + [row async for row in repository.export_rows(team_scope, export_type=ExportType.DAILY_WITH_KEYS)] + ) + assert tuple(row.api_key for row in exports) == ( + "key-a", + "key-b", + "key-c", + "key-cache", + "key-target", + ) + assert sum(row.spend for row in exports) == 273.0 + assert sum(row.flat_cost for row in exports) == 0.0 + deleted_key_export: Final = next(row for row in exports if row.api_key == "key-target") + assert (deleted_key_export.key_alias, deleted_key_export.user_id, deleted_key_export.user_email) == ( + "deleted-target", + "user-1", + "user@example.com", + ) + user_exports: Final = tuple( + [row async for row in repository.export_rows(team_scope, export_type=ExportType.DAILY_WITH_USERS)] + ) + assert len(user_exports) == 1 + assert (user_exports[0].user_id, user_exports[0].user_email, user_exports[0].spend) == ( + "user-1", + "user@example.com", + 273.0, + ) + daily_export: Final = tuple( + [row async for row in repository.export_rows(team_scope, export_type=ExportType.DAILY)] + ) + assert len(daily_export) == 1 + assert daily_export[0].spend == 1273.0 + assert daily_export[0].flat_cost == 42.0 + + metadata: Final = await repository.key_metadata(frozenset(("key-a", "key-target")), None) + assert metadata["key-a"].key_exists is True + assert metadata["key-a"].key_alias == "alias-a" + assert metadata["key-a"].tags == ("blue", "gold") + assert metadata["key-a"].user_email == "user@example.com" + assert metadata["key-target"].key_exists is False + assert metadata["key-target"].key_alias == "deleted-target" + assert metadata["key-target"].tags == ("archived",) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("search", "api_keys", "expected_keys"), + ( + ("needle-alias", None, ("needle-key",)), + ("needle-user", None, ("needle-key",)), + ("needle@example.com", None, ("needle-key",)), + ("needle-alias", ("key-a",), ()), + ), +) +async def test_search_keys_matches_token_metadata_outside_top_n_and_respects_scope( + search: str, + api_keys: tuple[str, ...] | None, + expected_keys: tuple[str, ...], +) -> None: + async with _daily_activity_database() as database: + await database.execute_raw( + """ + INSERT INTO "LiteLLM_DailyTeamSpend" ( + id, team_id, date, api_key, model, model_group, custom_llm_provider, + mcp_namespaced_tool_name, endpoint, prompt_tokens, completion_tokens, + cache_read_input_tokens, cache_creation_input_tokens, spend, api_requests, + successful_requests, failed_requests, ptu_flat_cost, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::timestamp + ) + """, + "needle-row", + "team-1", + "2026-06-01", + "needle-key", + "model-needle", + "", + "provider-a", + None, + "/v1/chat/completions", + 1, + 1, + 0, + 0, + 0.5, + 1, + 1, + 0, + 0.0, + "2026-06-01 12:00:00", + ) + await database.execute_raw( + """ + INSERT INTO "LiteLLM_VerificationToken" (token, key_alias, team_id, user_id, metadata, models) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::text[]) + """, + "needle-key", + "needle-alias", + "team-1", + "needle-user", + '{"tags": []}', + [], + ) + await database.execute_raw( + """ + INSERT INTO "LiteLLM_UserTable" (user_id, user_email, models) + VALUES ($1, $2, $3::text[]) + """, + "needle-user", + "needle@example.com", + [], + ) + + repository: Final = _repository(database) + team_scope: Final = _scope(DailyActivityTable.TEAM, "team_id", "team-1", api_keys=api_keys) + aggregate: Final = await repository.aggregated(team_scope, include_entity_breakdown=False, api_key_limit=1) + top_keys: Final = frozenset( + row.api_key for row in aggregate.grouping_rows if row.group_level == 31 and row.api_key + ) + assert "needle-key" not in top_keys + assert await repository.search_keys(team_scope, search=search, limit=10) == expected_keys + + +@pytest.mark.asyncio +async def test_aggregated_returns_totals_with_a_one_key_limit() -> None: + async with _daily_activity_database() as database: + aggregate: Final = await _repository(database).aggregated( + _scope(DailyActivityTable.TEAM, "team_id", "team-1"), + include_entity_breakdown=False, + api_key_limit=1, + ) + + totals: Final = tuple(row for row in aggregate.grouping_rows if row.group_level == 127) + per_key_rows: Final = tuple(row for row in aggregate.grouping_rows if row.api_key is not None) + per_key_names: Final = frozenset(row.api_key for row in per_key_rows) + assert len(totals) == 1 + assert totals[0].spend == 1273.0 + assert len(per_key_rows) == 6 + assert len(per_key_names) == 1 + + +@pytest.mark.asyncio +async def test_tag_entity_rollups_bound_keys_and_preserve_full_scope_totals() -> None: + async with _daily_activity_database(include_tag_activity=True) as database: + scope: Final = DailyActivityScope( + table=DailyActivityTable.TAG, + entity_id_field="tag", + entity_ids=None, + exclude_entity_ids=(), + api_keys=None, + start_date="2026-06-01", + end_date="2026-06-02", + model=None, + timezone_offset_minutes=None, + ) + repository: Final = _repository(database) + aggregate: Final = await repository.aggregated(scope, include_entity_breakdown=True, api_key_limit=3) + independent_metrics: Final = _TAG_ROLLUP_METRICS_ADAPTER.validate_python( + await database.query_raw( + """ + SELECT tag, date, SUM(spend)::float AS spend, + SUM(api_requests)::bigint AS api_requests, + SUM(prompt_tokens)::bigint AS prompt_tokens + FROM "LiteLLM_DailyTagSpend" + WHERE date >= $1 AND date <= $2 + GROUP BY tag, date + """, + "2026-06-01", + "2026-06-02", + ) + ) + independent_key_counts: Final = _TAG_API_KEY_COUNT_ADAPTER.validate_python( + await database.query_raw( + """ + SELECT tag, COUNT(DISTINCT api_key)::bigint AS distinct_api_keys + FROM "LiteLLM_DailyTagSpend" + WHERE date >= $1 AND date <= $2 AND api_key <> $3 + GROUP BY tag + """, + "2026-06-01", + "2026-06-02", + constants.PTU_SENTINEL_API_KEY, + ) + ) + independent_key_memberships: Final = _TAG_KEY_MEMBERSHIP_COUNT_ADAPTER.validate_python( + await database.query_raw( + """ + SELECT api_key, COUNT(DISTINCT tag)::bigint AS tag_count + FROM "LiteLLM_DailyTagSpend" + WHERE date >= $1 AND date <= $2 + GROUP BY api_key + """, + "2026-06-01", + "2026-06-02", + ) + ) + expected_metrics: Final = MappingProxyType({(row.date, row.tag): row for row in independent_metrics}) + expected_key_counts: Final = MappingProxyType( + {row.tag: row.distinct_api_keys for row in independent_key_counts} + ) + assert {row.date for row in independent_metrics} == {"2026-06-01", "2026-06-02"} + assert len(expected_key_counts) == 4 + assert len(independent_key_memberships) == 8 + assert all(row.tag_count == 2 for row in independent_key_memberships) + assert max(expected_key_counts.values()) > 3 + + entity_rows: Final = aggregate.entity_rows or () + rolled_rows: Final = tuple(row for row in entity_rows if row.api_key_rolled) + keyed_rows: Final = tuple(row for row in entity_rows if not row.api_key_rolled and row.api_key) + top_level_keys: Final = frozenset( + row.api_key for row in aggregate.grouping_rows if row.group_level == 31 and row.api_key + ) + entity_day_keys: Final = MappingProxyType( + { + key: frozenset(row.api_key for row in keyed_rows if (row.date, row.entity_id) == key and row.api_key) + for key in frozenset((row.date, row.entity_id) for row in keyed_rows) + } + ) + assert entity_day_keys + assert max(len(keys) for keys in entity_day_keys.values()) <= 3 + assert frozenset(row.api_key for row in keyed_rows) <= top_level_keys + + rolled_by_entity_day: Final = MappingProxyType( + {(row.date, row.entity_id): row for row in rolled_rows if row.date is not None} + ) + assert set(rolled_by_entity_day) == set(expected_metrics) + for key, row in rolled_by_entity_day.items(): + assert row.spend is not None + assert isclose(row.spend, expected_metrics[key].spend, rel_tol=1e-9, abs_tol=1e-9) + assert row.api_requests == expected_metrics[key].api_requests + assert row.prompt_tokens == expected_metrics[key].prompt_tokens + assert row.distinct_api_keys == expected_key_counts[row.entity_id] + + response: Final = await get_daily_activity_aggregated( + repository, + scope, + include_entity_breakdown=True, + api_key_limit=3, + ) + assert response.metadata.entity_total_api_keys == { + tag: count for tag, count in expected_key_counts.items() if tag is not None + } + assert all( + all(len(entity.api_key_breakdown) <= 3 for entity in day.breakdown.entities.values()) + for day in response.results + ) + + +@pytest.mark.asyncio +async def test_key_pages_match_full_tag_ranking_and_aggregate_top_keys() -> None: + async with _daily_activity_database(include_tag_activity=True) as database: + scope: Final = DailyActivityScope( + table=DailyActivityTable.TAG, + entity_id_field="tag", + entity_ids=None, + exclude_entity_ids=(), + api_keys=None, + start_date="2026-06-01", + end_date="2026-06-02", + model=None, + timezone_offset_minutes=None, + ) + repository: Final = _repository(database) + first_page: Final = await repository.key_page(scope, offset=0, limit=3) + remaining_pages: Final = tuple( + [ + await repository.key_page(scope, offset=offset, limit=3) + for offset in range(3, first_page.total_api_keys, 3) + ] + ) + pages: Final = (first_page, *remaining_pages) + actual_keys: Final = tuple(row.api_key for page in pages for row in page.rows) + expected_rows: Final = _TAG_RANKED_KEY_ADAPTER.validate_python( + await database.query_raw( + """ + SELECT api_key + FROM "LiteLLM_DailyTagSpend" + WHERE date >= $1 AND date <= $2 AND api_key <> $3 + GROUP BY api_key + ORDER BY SUM(spend::numeric) DESC, api_key + """, + "2026-06-01", + "2026-06-02", + constants.PTU_SENTINEL_API_KEY, + ) + ) + expected_keys: Final = tuple(row.api_key for row in expected_rows) + independent_count: Final = _TAG_DISTINCT_KEY_COUNT_ADAPTER.validate_python( + await database.query_raw( + """ + SELECT COUNT(DISTINCT api_key)::bigint AS total_api_keys + FROM "LiteLLM_DailyTagSpend" + WHERE date >= $1 AND date <= $2 AND api_key <> $3 + """, + "2026-06-01", + "2026-06-02", + constants.PTU_SENTINEL_API_KEY, + ) + )[0].total_api_keys + aggregate: Final = await repository.aggregated(scope, include_entity_breakdown=False, api_key_limit=3) + aggregate_top_keys: Final = frozenset( + row.api_key for row in aggregate.grouping_rows if row.group_level == 31 and row.api_key is not None + ) + empty_page: Final = await repository.key_page(scope, offset=independent_count + 3, limit=3) + + assert actual_keys == expected_keys + assert len(actual_keys) == len(frozenset(actual_keys)) + assert all(page.total_api_keys == independent_count for page in pages) + assert first_page.total_api_keys == independent_count + assert frozenset(row.api_key for row in first_page.rows) == aggregate_top_keys + assert empty_page.rows == () + assert empty_page.total_api_keys == independent_count + + +@pytest.mark.asyncio +async def test_top_api_key_rank_is_order_independent_for_float_ties() -> None: + async with _daily_activity_database(include_tag_float_tie_activity=True) as database: + float_totals: Final = _TAG_FLOAT_SPEND_ADAPTER.validate_python( + await database.query_raw( + """ + SELECT api_key, SUM(spend)::float AS spend + FROM "LiteLLM_DailyTagSpend" + WHERE tag = $1 AND date = $2 + GROUP BY api_key + """, + "tag-float-tie", + "2026-06-01", + ) + ) + float_spends: Final = MappingProxyType({row.api_key: row.spend for row in float_totals}) + assert float_spends["key-z"] > float_spends["key-a"] + + scope: Final = DailyActivityScope( + table=DailyActivityTable.TAG, + entity_id_field="tag", + entity_ids=None, + exclude_entity_ids=(), + api_keys=None, + start_date="2026-06-01", + end_date="2026-06-01", + model=None, + timezone_offset_minutes=None, + ) + aggregate: Final = await _repository(database).aggregated(scope, include_entity_breakdown=True, api_key_limit=1) + key_page: Final = await _repository(database).key_page(scope, offset=0, limit=1) + top_level_keys: Final = frozenset( + row.api_key for row in aggregate.grouping_rows if row.group_level == 31 and row.api_key + ) + entity_keyed_keys: Final = frozenset( + row.api_key for row in aggregate.entity_rows or () if not row.api_key_rolled and row.api_key is not None + ) + expected_keys: Final = frozenset(("key-a",)) + assert (top_level_keys, entity_keyed_keys) == ( + expected_keys, + expected_keys, + ), f"plain float SUM totals: {float_spends}" + assert frozenset(row.api_key for row in key_page.rows) == expected_keys + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("table", "entity_field", "entity_id", "fixture_name"), + ( + (DailyActivityTable.USER, "user_id", "user-1", "daily_activity_user.json"), + (DailyActivityTable.TEAM, "team_id", "team-1", "daily_activity_team.json"), + ), +) +async def test_aggregated_response_matches_base_golden( + table: DailyActivityTable, entity_field: str, entity_id: str, fixture_name: str +) -> None: + async with _daily_activity_database() as database: + result: Final = await get_daily_activity_aggregated( + _repository(database), + _scope(table, entity_field, entity_id), + entity_metadata_field=MappingProxyType({"team-1": {"team_alias": "Usage Team"}}), + include_entity_breakdown=True, + ) + golden_path: Final = Path(__file__).with_name("fixtures") / fixture_name + assert result.model_dump_json() + "\n" == golden_path.read_text() + + +@pytest.mark.asyncio +async def test_team_entity_rollups_merge_null_and_empty_entity_ids() -> None: + async with _daily_activity_database(include_team_unassigned_activity=True) as database: + scope: Final = DailyActivityScope( + table=DailyActivityTable.TEAM, + entity_id_field="team_id", + entity_ids=None, + exclude_entity_ids=(), + api_keys=None, + start_date="2026-06-03", + end_date="2026-06-03", + model=None, + timezone_offset_minutes=None, + ) + aggregate: Final = await _repository(database).aggregated(scope, include_entity_breakdown=True, api_key_limit=3) + + totals: Final = tuple(row for row in aggregate.grouping_rows if row.group_level == 127) + assert len(totals) == 1 + assert totals[0].spend == 23.0 + assert aggregate.distinct_api_keys == 2 + + rolled_rows: Final = tuple(row for row in aggregate.entity_rows or () if row.api_key_rolled) + assert len(rolled_rows) == 1 + assert rolled_rows[0].entity_id == "" + assert rolled_rows[0].spend == 23.0 + assert rolled_rows[0].ptu_flat_cost == 13.0 + assert rolled_rows[0].distinct_api_keys == 2 + + keyed_rows: Final = tuple(row for row in aggregate.entity_rows or () if not row.api_key_rolled) + assert {row.entity_id for row in keyed_rows} == {""} + assert {row.api_key for row in keyed_rows} == {"key-unassigned-null", "key-unassigned-empty"} diff --git a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py index 7cc5100037e..75e4a865137 100644 --- a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py @@ -1,28 +1,200 @@ -from collections.abc import Sequence -from datetime import datetime, timedelta, timezone +from collections.abc import Mapping, Sequence +from datetime import datetime from types import SimpleNamespace from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest +from fastapi import HTTPException +import litellm.proxy.management_endpoints.common_daily_activity as common_daily_activity_module +from litellm.constants import USAGE_TOP_API_KEYS_DEFAULT from litellm.proxy.management_endpoints.common_daily_activity import ( - _adjust_dates_for_timezone, - _build_aggregated_sql_query, - _build_entity_rollup_sql_query, _is_user_agent_tag, + _ProxyDailyActivityReads, _record_to_spend_metrics, + compute_tag_metadata_totals, + daily_activity_repository, + daily_activity_scope, get_api_key_metadata, get_daily_activity, - get_daily_activity_aggregated, update_metrics, ) +from litellm.proxy.management_endpoints.common_daily_activity import ( + get_daily_activity_aggregated as _get_daily_activity_aggregated, +) from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR -from litellm.proxy.utils import hash_token +from litellm.proxy.utils import PrismaClient, hash_token from litellm.types.proxy.management_endpoints.common_daily_activity import ( DailySpendMetadata, + SpendAnalyticsPaginatedResponse, SpendMetrics, ) +from litellm.types.repositories.daily_activity import GroupingSetsRow, KeyMetadataRow + + +async def _run_aggregated_daily_activity( + *, + prisma_client: PrismaClient, + table_name: str, + entity_id_field: str, + entity_id: str | list[str] | None, + entity_metadata_field: Mapping[str, dict[str, object]] | None = None, + start_date: str, + end_date: str, + model: str | None, + api_key: str | list[str] | None, + exclude_entity_ids: list[str] | None = None, + timezone_offset_minutes: int | None = None, + include_current_utc_day: bool = False, + include_entity_breakdown: bool = False, + api_key_limit: int = USAGE_TOP_API_KEYS_DEFAULT, +) -> SpendAnalyticsPaginatedResponse: + repository: Final = daily_activity_repository(prisma_client) + scope: Final = daily_activity_scope( + table_name, + entity_id_field, + entity_id, + exclude_entity_ids, + api_key, + start_date, + end_date, + model, + timezone_offset_minutes, + include_current_utc_day, + ) + return await _get_daily_activity_aggregated( + repository, + scope, + entity_metadata_field=entity_metadata_field, + include_entity_breakdown=include_entity_breakdown, + api_key_limit=api_key_limit, + ) + + +async def get_daily_activity_aggregated( + *, + prisma_client: PrismaClient, + table_name: str, + entity_id_field: str, + entity_id: str | list[str] | None, + entity_metadata_field: Mapping[str, dict[str, object]] | None = None, + start_date: str, + end_date: str, + model: str | None, + api_key: str | list[str] | None, + exclude_entity_ids: list[str] | None = None, + timezone_offset_minutes: int | None = None, + include_current_utc_day: bool = False, + include_entity_breakdown: bool = False, + api_key_limit: int = USAGE_TOP_API_KEYS_DEFAULT, +) -> SpendAnalyticsPaginatedResponse: + return await _run_aggregated_daily_activity( + prisma_client=prisma_client, + table_name=table_name, + entity_id_field=entity_id_field, + entity_id=entity_id, + entity_metadata_field=entity_metadata_field, + start_date=start_date, + end_date=end_date, + model=model, + api_key=api_key, + exclude_entity_ids=exclude_entity_ids, + timezone_offset_minutes=timezone_offset_minutes, + include_current_utc_day=include_current_utc_day, + include_entity_breakdown=include_entity_breakdown, + api_key_limit=api_key_limit, + ) + + +@pytest.mark.asyncio +async def test_get_daily_activity_requires_a_database(): + with pytest.raises(HTTPException) as error: + await get_daily_activity( + prisma_client=None, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id="user-1", + entity_metadata_field=None, + start_date="2026-06-16", + end_date="2026-06-16", + model=None, + api_key=None, + page=1, + page_size=10, + ) + + assert error.value.status_code == 500 + assert error.value.detail == {"error": common_daily_activity_module.CommonProxyErrors.db_not_connected_error.value} + + +@pytest.mark.asyncio +async def test_get_daily_activity_maps_repository_failures_to_http_errors(): + mock_prisma = MagicMock() + mock_prisma.db = MagicMock() + mock_table = MagicMock() + mock_table.count = AsyncMock(return_value=0) + mock_table.find_many = AsyncMock(side_effect=RuntimeError("daily rows unavailable")) + mock_prisma.db.litellm_dailyuserspend = mock_table + + with pytest.raises(HTTPException) as error: + await get_daily_activity( + prisma_client=mock_prisma, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id="user-1", + entity_metadata_field=None, + start_date="2026-06-16", + end_date="2026-06-16", + model=None, + api_key=None, + page=1, + page_size=10, + ) + + assert error.value.status_code == 500 + assert error.value.detail == {"error": "Failed to fetch analytics: daily rows unavailable"} + + +@pytest.mark.asyncio +async def test_get_daily_activity_aggregated_maps_repository_failures_to_http_errors(): + repository = MagicMock() + repository.aggregated = AsyncMock(side_effect=RuntimeError("daily aggregate unavailable")) + scope = daily_activity_scope( + "litellm_dailyuserspend", + "user_id", + "user-1", + None, + None, + "2026-06-16", + "2026-06-16", + None, + None, + ) + + with pytest.raises(HTTPException) as error: + await _get_daily_activity_aggregated(repository, scope) + + assert error.value.status_code == 500 + assert error.value.detail == {"error": "Failed to fetch analytics: daily aggregate unavailable"} + + +def test_compute_tag_metadata_totals_deduplicates_and_ignores_user_agent_tags(): + smaller = _spend_record("key-1", spend=1.0) + smaller.request_id = "request-1" + smaller.tag = "environment: small" + larger = _spend_record("key-1", spend=4.0) + larger.request_id = "request-1" + larger.tag = "environment: large" + larger.api_requests = 1 + user_agent = _spend_record("key-2", spend=10.0) + user_agent.request_id = "request-2" + user_agent.tag = "User-Agent: test" + user_agent.api_requests = 3 + + totals = compute_tag_metadata_totals((smaller, larger, user_agent)) + + assert (totals.spend, totals.api_requests) == (4.0, 1) @pytest.mark.asyncio @@ -39,12 +211,12 @@ async def test_get_daily_activity_empty_entity_id_list(): mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) # Set the table name dynamically - mock_prisma.db.litellm_dailyspend = mock_table + mock_prisma.db.litellm_dailyteamspend = mock_table # Call the function with empty entity_id list - result = await get_daily_activity( + await get_daily_activity( prisma_client=mock_prisma, - table_name="litellm_dailyspend", + table_name="litellm_dailyteamspend", entity_id_field="team_id", entity_id=[], entity_metadata_field=None, @@ -87,11 +259,11 @@ async def test_get_daily_activity_order_has_id_tiebreaker(): mock_table.find_many = AsyncMock(return_value=[]) mock_prisma.db.litellm_verificationtoken = MagicMock() mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) - mock_prisma.db.litellm_dailyspend = mock_table + mock_prisma.db.litellm_dailyteamspend = mock_table await get_daily_activity( prisma_client=mock_prisma, - table_name="litellm_dailyspend", + table_name="litellm_dailyteamspend", entity_id_field="team_id", entity_id="team-1", entity_metadata_field=None, @@ -105,7 +277,7 @@ async def test_get_daily_activity_order_has_id_tiebreaker(): mock_table.find_many.assert_called_once() order = mock_table.find_many.call_args[1]["order"] - assert order == [{"date": "desc"}, {"id": "asc"}], ( + assert order == ({"date": "desc"}, {"id": "asc"}), ( f"order must include the id tiebreaker after date for stable offset pagination (see #30164); got {order!r}" ) @@ -169,6 +341,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown(): "endpoint": "/v1/chat/completions", "api_key": None, "group_level": 62, + "distinct_api_keys": None, "spend": 15.0, "prompt_tokens": 150, "completion_tokens": 75, @@ -181,31 +354,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown(): "endpoint": "/v1/embeddings", "api_key": None, "group_level": 62, - "spend": 3.0, - "prompt_tokens": 30, - "completion_tokens": 0, - "api_requests": 1, - "successful_requests": 1, - }, - # (date, endpoint, api_key) — populates the per-key sub-bucket - { - **base, - "date": "2024-01-01", - "endpoint": "/v1/chat/completions", - "api_key": "key-1", - "group_level": 30, - "spend": 15.0, - "prompt_tokens": 150, - "completion_tokens": 75, - "api_requests": 2, - "successful_requests": 2, - }, - { - **base, - "date": "2024-01-01", - "endpoint": "/v1/embeddings", - "api_key": "key-2", - "group_level": 30, + "distinct_api_keys": None, "spend": 3.0, "prompt_tokens": 30, "completion_tokens": 0, @@ -219,6 +368,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown(): "endpoint": None, "api_key": None, "group_level": 63, + "distinct_api_keys": None, "spend": 18.0, "prompt_tokens": 180, "completion_tokens": 75, @@ -232,12 +382,40 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown(): "endpoint": None, "api_key": None, "group_level": 127, + "distinct_api_keys": None, "spend": 18.0, "prompt_tokens": 180, "completion_tokens": 75, "api_requests": 3, "successful_requests": 3, }, + # (date, endpoint, api_key) — populates the per-key sub-bucket + { + **base, + "date": "2024-01-01", + "endpoint": "/v1/chat/completions", + "api_key": "key-1", + "group_level": 30, + "distinct_api_keys": 2, + "spend": 15.0, + "prompt_tokens": 150, + "completion_tokens": 75, + "api_requests": 2, + "successful_requests": 2, + }, + { + **base, + "date": "2024-01-01", + "endpoint": "/v1/embeddings", + "api_key": "key-2", + "group_level": 30, + "distinct_api_keys": 2, + "spend": 3.0, + "prompt_tokens": 30, + "completion_tokens": 0, + "api_requests": 1, + "successful_requests": 1, + }, ] mock_prisma.db.query_raw = AsyncMock(return_value=mock_rows) @@ -313,6 +491,49 @@ async def test_get_api_key_metadata_returns_active_key_metadata(): assert result["active-key-hash-123"]["team_id"] == "team-abc" +@pytest.mark.asyncio +async def test_recovered_key_metadata_preserves_resolved_tags_after_user_details( + monkeypatch: pytest.MonkeyPatch, +) -> None: + resolved: Final = KeyMetadataRow( + api_key="key-hash", + key_alias="key alias", + team_id="team-id", + user_id="user-id", + user_email=None, + key_exists=True, + tags=("production", "internal"), + ) + attach_details: Final = AsyncMock( + return_value={ + "key-hash": { + "key_alias": "key alias", + "team_id": "team-id", + "user_id": "user-id", + "user_email": "user@example.com", + "key_exists": True, + } + } + ) + monkeypatch.setattr(common_daily_activity_module, "attach_user_details", attach_details) + reads: Final = _ProxyDailyActivityReads(MagicMock()) + + result: Final = await reads.recover_key_metadata({"key-hash": resolved}, frozenset(("key-hash",)), None) + + assert result == { + "key-hash": KeyMetadataRow( + api_key="key-hash", + key_alias="key alias", + team_id="team-id", + user_id="user-id", + user_email="user@example.com", + key_exists=True, + tags=("production", "internal"), + ) + } + attach_details.assert_awaited_once() + + @pytest.mark.asyncio async def test_get_api_key_metadata_falls_back_to_deleted_keys(): """Test that get_api_key_metadata should fall back to deleted keys table for missing keys.""" @@ -341,7 +562,6 @@ async def test_get_api_key_metadata_falls_back_to_deleted_keys(): # Verify deleted table was queried with the missing key mock_prisma.db.litellm_deletedverificationtoken.find_many.assert_called_once_with( where={"token": {"in": ["deleted-key-hash-456"]}}, - order={"deleted_at": "desc"}, ) @@ -437,11 +657,13 @@ async def test_get_api_key_metadata_regenerated_key_uses_most_recent_deleted_rec mock_deleted_1.token = "old-key-hash" mock_deleted_1.key_alias = "latest-alias" mock_deleted_1.team_id = "latest-team" + mock_deleted_1.deleted_at = datetime(2024, 1, 2) mock_deleted_2 = MagicMock() mock_deleted_2.token = "old-key-hash" mock_deleted_2.key_alias = "older-alias" mock_deleted_2.team_id = "older-team" + mock_deleted_2.deleted_at = datetime(2024, 1, 1) # Ordered by deleted_at desc, so first record is the most recent mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[mock_deleted_1, mock_deleted_2]) @@ -629,12 +851,15 @@ def test_key_metadata_includes_recovered_user_email(): meta = _key_metadata( { - "dirty-key": { - "key_alias": "batch-worker", - "team_id": "team-1", - "user_id": "alice", - "user_email": "alice@example.com", - } + "dirty-key": KeyMetadataRow( + api_key="dirty-key", + key_alias="batch-worker", + team_id="team-1", + user_id="alice", + user_email="alice@example.com", + key_exists=True, + tags=(), + ) }, "dirty-key", ) @@ -649,11 +874,15 @@ def test_key_metadata_includes_user_id_without_user_email(): meta = _key_metadata( { - "dirty-key": { - "key_alias": "batch-worker", - "team_id": "team-1", - "user_id": "user-123", - } + "dirty-key": KeyMetadataRow( + api_key="dirty-key", + key_alias="batch-worker", + team_id="team-1", + user_id="user-123", + user_email=None, + key_exists=True, + tags=(), + ) }, "dirty-key", ) @@ -694,11 +923,15 @@ def test_update_breakdown_metrics_includes_user_email(): user_id="alice", ) api_key_metadata = { - "dirty-key": { - "key_alias": "batch-worker", - "team_id": "team-1", - "user_email": "alice@example.com", - } + "dirty-key": KeyMetadataRow( + api_key="dirty-key", + key_alias="batch-worker", + team_id="team-1", + user_id=None, + user_email="alice@example.com", + key_exists=True, + tags=(), + ) } update_breakdown_metrics( @@ -870,6 +1103,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys(): "endpoint": "/v1/chat/completions", "api_key": None, "group_level": 62, + "distinct_api_keys": None, "spend": 10.0, "prompt_tokens": 100, "completion_tokens": 50, @@ -882,6 +1116,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys(): "endpoint": "/v1/chat/completions", "api_key": "deleted-key-hash", "group_level": 30, + "distinct_api_keys": 1, "spend": 10.0, "prompt_tokens": 100, "completion_tokens": 50, @@ -963,10 +1198,21 @@ async def test_aggregated_activity_flags_only_keys_that_key_info_can_still_resol return_value=[{**base, "api_key": key} for key in ("active-key", "deleted-key", "session-key")] ) mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock( - return_value=[SimpleNamespace(token="active-key", key_alias="active", team_id=None, user_id="owner")] + return_value=[ + SimpleNamespace(token="active-key", key_alias="active", team_id=None, user_id="owner", metadata=None) + ] ) mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock( - return_value=[SimpleNamespace(token="deleted-key", key_alias="deleted", team_id=None, user_id="owner")] + return_value=[ + SimpleNamespace( + token="deleted-key", + key_alias="deleted", + team_id=None, + user_id="owner", + metadata=None, + deleted_at=datetime(2024, 1, 2), + ) + ] ) mock_prisma.db.litellm_usertable.find_many = AsyncMock(return_value=[]) @@ -1128,261 +1374,6 @@ async def test_model_groups_breakdown_keys_by_public_name_with_model_fallback(): assert breakdown.models["claude-x"].metrics.spend == 2.0 -class TestAdjustDatesForTimezone: - """ - Regression tests for the timezone double-counting bug. - - Background: the previous implementation expanded the SQL date range by a full - UTC day on whichever side a non-UTC timezone offset pointed. Because spend is - bucketed in whole UTC days in the aggregation table, that expansion caused - single-day queries from non-UTC timezones to include a second full UTC day's - worth of data, producing approximately 2x over-counting. The sum of single-day - spends across a window then exceeded the equivalent multi-day aggregate, which - is mathematically impossible. - - These tests pin the function to a pass-through and assert the additivity - invariant that any future implementation must preserve. - """ - - @pytest.mark.parametrize( - "offset_minutes", - [ - None, - 0, - -330, # IST UTC+5:30 - -540, # JST UTC+9 - -60, # CET UTC+1 - 240, # AST UTC-4 - 300, # EST UTC-5 - 480, # PST UTC-8 - ], - ) - def test_returns_input_dates_unchanged_for_any_offset(self, offset_minutes): - start, end = _adjust_dates_for_timezone("2026-05-29", "2026-05-29", offset_minutes) - assert start == "2026-05-29" - assert end == "2026-05-29" - - def test_single_day_query_does_not_widen_to_two_utc_days(self): - """ - Pins the boundary that caused the original 2x bug: a single IST day must - not be translated into a SQL filter covering two UTC days. - """ - start, end = _adjust_dates_for_timezone("2026-05-29", "2026-05-29", -330) - assert start == end == "2026-05-29", ( - "Single-day IST query expanded to a multi-day UTC range; this is " - "the regression that produced approximately 2x over-counting." - ) - - def test_multi_day_range_endpoints_are_preserved(self): - start, end = _adjust_dates_for_timezone("2026-05-29", "2026-06-02", -330) - assert (start, end) == ("2026-05-29", "2026-06-02") - - @pytest.mark.parametrize("offset_minutes", [-330, 480]) - def test_single_day_sums_match_multi_day_window(self, offset_minutes): - """ - Additivity invariant: querying each day in a window separately and summing - the resulting SQL ranges must cover exactly the same range as querying the - whole window at once. The bug broke this; without it, single-day sums - exceeded the multi-day total by ~50% over a 5-day IST window. - """ - days = ["2026-05-29", "2026-05-30", "2026-05-31", "2026-06-01", "2026-06-02"] - single_day_ranges = [_adjust_dates_for_timezone(d, d, offset_minutes) for d in days] - multi_day_range = _adjust_dates_for_timezone(days[0], days[-1], offset_minutes) - - per_day_starts = [r[0] for r in single_day_ranges] - per_day_ends = [r[1] for r in single_day_ranges] - assert min(per_day_starts) == multi_day_range[0] - assert max(per_day_ends) == multi_day_range[1] - assert per_day_starts == days - assert per_day_ends == days - - -class TestAdjustDatesForTimezoneLiveEnd: - """ - Regression tests for the stale-evening bug: a caller west of UTC whose range - ends on their local "today" was capped at that local date's UTC bucket, so - once UTC rolled past their local midnight (5pm PT), everything sent that - evening sat in the next UTC bucket and the dashboard reported $0 for it - until local midnight. A range that reaches the caller's current day and - opts in via include_current_utc_day must extend to today's UTC bucket; the - only part of that bucket outside the range is the future, which is empty, - so the extension cannot over-count. Callers that do not opt in keep the - pass-through byte for byte. - """ - - PT_EVENING_UTC: Final = datetime(2026, 8, 6, 4, 30, tzinfo=timezone.utc) - - def test_pt_evening_range_ending_today_extends_to_utc_today(self): - start, end = _adjust_dates_for_timezone( - "2026-07-06", "2026-08-05", 420, include_current_utc_day=True, utc_now=self.PT_EVENING_UTC - ) - assert (start, end) == ("2026-07-06", "2026-08-06") - - def test_without_opt_in_live_range_keeps_pass_through(self): - start, end = _adjust_dates_for_timezone("2026-07-06", "2026-08-05", 420, utc_now=self.PT_EVENING_UTC) - assert (start, end) == ("2026-07-06", "2026-08-05") - - def test_pt_historical_range_is_untouched(self): - start, end = _adjust_dates_for_timezone( - "2026-07-01", "2026-08-04", 420, include_current_utc_day=True, utc_now=self.PT_EVENING_UTC - ) - assert (start, end) == ("2026-07-01", "2026-08-04") - - def test_east_of_utc_local_today_already_covers_utc_today(self): - ist_evening_utc: Final = datetime(2026, 8, 5, 17, 0, tzinfo=timezone.utc) - start, end = _adjust_dates_for_timezone( - "2026-07-07", "2026-08-06", -330, include_current_utc_day=True, utc_now=ist_evening_utc - ) - assert (start, end) == ("2026-07-07", "2026-08-06") - - def test_missing_offset_stays_pass_through_even_for_live_range(self): - start, end = _adjust_dates_for_timezone( - "2026-07-06", "2026-08-05", None, include_current_utc_day=True, utc_now=self.PT_EVENING_UTC - ) - assert (start, end) == ("2026-07-06", "2026-08-05") - - def test_utc_caller_range_ending_today_is_unchanged(self): - utc_noon: Final = datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) - start, end = _adjust_dates_for_timezone( - "2026-07-06", "2026-08-05", 0, include_current_utc_day=True, utc_now=utc_noon - ) - assert (start, end) == ("2026-07-06", "2026-08-05") - - def test_future_end_date_extends_no_further_than_requested(self): - start, end = _adjust_dates_for_timezone( - "2026-07-06", "2026-08-09", 420, include_current_utc_day=True, utc_now=self.PT_EVENING_UTC - ) - assert (start, end) == ("2026-07-06", "2026-08-09") - - -class TestBuildAggregatedSqlQuery: - """ - Asserts the SQL emitted by the aggregated query path stays anchored to the - user-supplied date range. The original bug shipped a function that returned - expanded dates from _adjust_dates_for_timezone, so the regression surface is - not just the helper but the SQL it feeds into. - """ - - @pytest.mark.parametrize("offset_minutes", [None, 0, -330, 480]) - def test_sql_date_bounds_are_user_supplied_dates(self, offset_minutes): - sql, params = _build_aggregated_sql_query( - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id="user-1", - start_date="2026-05-29", - end_date="2026-05-29", - model=None, - api_key=None, - timezone_offset_minutes=offset_minutes, - ) - - assert params[0] == "2026-05-29" - assert params[1] == "2026-05-29" - assert "date >= $1" in sql - assert "date <= $2" in sql - - @pytest.mark.parametrize("build", [_build_aggregated_sql_query, _build_entity_rollup_sql_query]) - def test_include_current_utc_day_extends_live_end_bound(self, build): - """ - An offset larger than 24h keeps the caller's local date behind UTC at any - wall-clock hour, so the live-end extension is deterministic: a range ending - on the caller's local today must reach today's UTC bucket (LIT-5818, guards - the #36051 behavior on the aggregated path). - """ - offset_minutes: Final = 1500 - caller_local_today: Final = (datetime.now(timezone.utc) - timedelta(minutes=offset_minutes)).date().isoformat() - utc_today: Final = datetime.now(timezone.utc).date().isoformat() - - _sql, params = build( - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id="user-1", - start_date="2026-05-01", - end_date=caller_local_today, - model=None, - api_key=None, - timezone_offset_minutes=offset_minutes, - include_current_utc_day=True, - ) - - assert params[0] == "2026-05-01" - assert params[1] == utc_today - - def test_optional_filters_appear_in_params_in_order(self): - sql, params = _build_aggregated_sql_query( - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id="user-1", - start_date="2026-05-29", - end_date="2026-06-02", - model="bedrock/global.anthropic.claude-opus-4-8", - api_key="sk-test", - timezone_offset_minutes=-330, - ) - - assert params == [ - "2026-05-29", - "2026-06-02", - "user-1", - "bedrock/global.anthropic.claude-opus-4-8", - "sk-test", - ] - assert "model = $4" in sql - assert "api_key = $5" in sql - - -class TestAggregatedEmptyEntityFilter: - _BUILDERS: Final = (_build_aggregated_sql_query, _build_entity_rollup_sql_query) - - @pytest.mark.parametrize("build", _BUILDERS) - def test_empty_entity_list_emits_no_degenerate_in_clause(self, build): - sql, params = build( - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=[], - start_date="2026-08-01", - end_date="2026-08-19", - model=None, - api_key=None, - ) - - normalized = " ".join(sql.split()) - assert "IN ()" not in normalized - assert '"team_id" IN' not in normalized - assert params == ["2026-08-01", "2026-08-19"] - - @pytest.mark.parametrize("build", _BUILDERS) - def test_empty_entity_list_matches_nothing_rather_than_everything(self, build): - sql, _ = build( - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=[], - start_date="2026-08-01", - end_date="2026-08-19", - model=None, - api_key=None, - ) - - assert "FALSE" in " ".join(sql.split()) - - @pytest.mark.parametrize("build", _BUILDERS) - def test_populated_entity_list_still_filters_on_its_ids(self, build): - sql, params = build( - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=["team-alpha", "team-beta"], - start_date="2026-08-01", - end_date="2026-08-19", - model=None, - api_key=None, - ) - - normalized = " ".join(sql.split()) - assert '"team_id" IN ($3, $4)' in normalized - assert "FALSE" not in normalized - assert params == ["2026-08-01", "2026-08-19", "team-alpha", "team-beta"] - - @pytest.mark.asyncio async def test_get_daily_activity_aggregated_empty_result_set(): """Regression test for the empty-range 500. @@ -1405,6 +1396,7 @@ async def test_get_daily_activity_aggregated_empty_result_set(): "mcp_namespaced_tool_name": None, "endpoint": None, "group_level": 127, + "distinct_api_keys": None, "spend": None, "prompt_tokens": None, "completion_tokens": None, @@ -1438,6 +1430,7 @@ async def test_get_daily_activity_aggregated_empty_result_set(): assert result.results == [] assert result.metadata.total_spend == 0.0 + assert result.metadata.entity_total_api_keys is None assert result.metadata.total_prompt_tokens == 0 assert result.metadata.total_completion_tokens == 0 assert result.metadata.total_tokens == 0 @@ -1517,20 +1510,6 @@ class TestEverySavingsDriverSurvivesTheReadPath: assert drivers, "expected the dashboard response to expose at least one savings driver" return drivers - def test_every_driver_is_summed_by_the_rollup_query(self): - sql, _ = _build_aggregated_sql_query( - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id="user-1", - start_date="2026-07-01", - end_date="2026-07-31", - model=None, - api_key=None, - timezone_offset_minutes=None, - ) - for driver in self._drivers(): - assert f"SUM({driver})" in sql, f"{driver} is never summed, so it reads as zero" - def test_every_driver_is_accumulated_across_rows(self): for driver in self._drivers(): record = _no_spend_record() @@ -1558,20 +1537,6 @@ class TestResponseTimeSurvivesTheReadPath: _FIELDS = ("total_response_time_ms", "timed_requests") - def test_both_halves_are_summed_by_the_rollup_query(self): - sql, _ = _build_aggregated_sql_query( - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id="user-1", - start_date="2026-09-01", - end_date="2026-09-30", - model=None, - api_key=None, - timezone_offset_minutes=None, - ) - for field in self._FIELDS: - assert f"SUM({field})" in sql, f"{field} is never summed, so the average reads as zero" - def test_accumulating_rows_keeps_sum_and_count_paired(self): first = _no_spend_record() first.total_response_time_ms = 1500 @@ -1608,6 +1573,12 @@ def ptu_cost_attribution_enabled(monkeypatch): def _spend_record(api_key, *, model="gpt-4o-mini-ptu", spend=0.0, ptu_flat_cost=0.0): return SimpleNamespace( api_key=api_key, + user_id=None, + team_id=None, + tag=None, + organization_id=None, + end_user_id=None, + agent_id=None, model=model, model_group=None, mcp_namespaced_tool_name=None, @@ -1630,6 +1601,7 @@ def _spend_record(api_key, *, model="gpt-4o-mini-ptu", spend=0.0, ptu_flat_cost= successful_requests=0, failed_requests=0, ptu_flat_cost=ptu_flat_cost, + request_id=None, ) @@ -1669,9 +1641,7 @@ def _grouping_row( spend=0.0, ptu_flat_cost=0.0, ): - from litellm.proxy.management_endpoints.common_daily_activity import _GroupingSetsRow - - return _GroupingSetsRow( + return GroupingSetsRow( date="2024-01-01", api_key=api_key, model=model, @@ -1680,6 +1650,7 @@ def _grouping_row( mcp_namespaced_tool_name=mcp_namespaced_tool_name, endpoint=endpoint, group_level=group_level, + distinct_api_keys=None, spend=spend, ptu_flat_cost=ptu_flat_cost, prompt_tokens=0, @@ -2186,57 +2157,6 @@ class TestFlagIsNotReadOnTheHotPath: assert reads > 0 -def test_entity_rollup_sql_query_and_api_key_list_filter(): - """The entity rollup companion query keeps its own two grouping sets keyed - by GROUPING(api_key), shares the WHERE builder (list api_key becomes a - parameterized IN, an empty list must match nothing), and the main - aggregated query stays entity-free.""" - from litellm.proxy.management_endpoints.common_daily_activity import ( - _build_entity_rollup_sql_query, - ) - - sql, params = _build_entity_rollup_sql_query( - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=None, - start_date="2024-01-01", - end_date="2024-01-31", - model=None, - api_key=["key-1", "key-2"], - ) - assert '"team_id" AS entity_id' in sql - assert "GROUPING(api_key) AS api_key_rolled" in sql - assert '(date, "team_id"),' in sql - assert '(date, "team_id", api_key)' in sql - assert "api_key IN ($3, $4)" in sql - assert "SUM(ptu_flat_cost)::float" in sql - assert params == ["2024-01-01", "2024-01-31", "key-1", "key-2"] - - plain_sql, _ = _build_aggregated_sql_query( - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=None, - start_date="2024-01-01", - end_date="2024-01-31", - model=None, - api_key=None, - ) - assert "entity_id" not in plain_sql - assert "GROUPING(date" in plain_sql - - empty_sql, empty_params = _build_aggregated_sql_query( - table_name="litellm_dailyteamspend", - entity_id_field="team_id", - entity_id=None, - start_date="2024-01-01", - end_date="2024-01-31", - model=None, - api_key=[], - ) - assert "FALSE" in empty_sql - assert empty_params == ["2024-01-01", "2024-01-31"] - - @pytest.mark.asyncio async def test_get_daily_activity_aggregated_with_entity_breakdown(): """include_entity_breakdown must run the companion entity rollup query and @@ -2268,19 +2188,44 @@ async def test_get_daily_activity_aggregated_with_entity_breakdown(): "successful_requests": 0, } main_rows = [ - {**base, "date": None, "group_level": 127, "spend": 18.0}, - {**base, "date": "2024-01-01", "group_level": 63, "spend": 18.0}, - {**base, "date": "2024-01-01", "model": "gpt-4o", "group_level": 47, "spend": 18.0}, - {**base, "date": "2024-01-01", "api_key": "key-1", "group_level": 31, "spend": 12.0}, + {**base, "date": None, "group_level": 127, "distinct_api_keys": None, "spend": 18.0}, + {**base, "date": "2024-01-01", "group_level": 63, "distinct_api_keys": None, "spend": 18.0}, + {**base, "date": "2024-01-01", "model": "gpt-4o", "group_level": 47, "distinct_api_keys": None, "spend": 18.0}, + {**base, "date": "2024-01-01", "api_key": "key-1", "group_level": 31, "distinct_api_keys": 1, "spend": 12.0}, ] - entity_base = { - key: value - for key, value in base.items() - if key not in ("model", "model_group", "custom_llm_provider", "mcp_namespaced_tool_name", "endpoint") + entity_base: Final = { + **{ + key: value + for key, value in base.items() + if key not in ("model", "model_group", "custom_llm_provider", "mcp_namespaced_tool_name", "endpoint") + }, + "distinct_api_keys": None, } entity_rows = [ - {**entity_base, "date": "2024-01-01", "entity_id": "team-a", "api_key_rolled": 1, "spend": 12.0}, - {**entity_base, "date": "2024-01-01", "entity_id": "team-b", "api_key_rolled": 1, "spend": 6.0}, + { + **entity_base, + "date": "2024-01-01", + "entity_id": "team-a", + "api_key_rolled": 1, + "distinct_api_keys": 3, + "spend": 12.0, + }, + { + **entity_base, + "date": "2024-01-01", + "entity_id": "team-b", + "api_key_rolled": 1, + "distinct_api_keys": 2, + "spend": 4.0, + }, + { + **entity_base, + "date": "2024-01-01", + "entity_id": None, + "api_key_rolled": 1, + "distinct_api_keys": 1, + "spend": 2.0, + }, { **entity_base, "date": "2024-01-01", @@ -2295,7 +2240,17 @@ async def test_get_daily_activity_aggregated_with_entity_breakdown(): "entity_id": "team-b", "api_key": "key-2", "api_key_rolled": 0, - "spend": 6.0, + "distinct_api_keys": None, + "spend": 4.0, + }, + { + **entity_base, + "date": "2024-01-01", + "entity_id": None, + "api_key": "key-3", + "api_key_rolled": 0, + "distinct_api_keys": None, + "spend": 2.0, }, ] @@ -2320,22 +2275,25 @@ async def test_get_daily_activity_aggregated_with_entity_breakdown(): main_sql = mock_prisma.db.query_raw.call_args_list[0][0][0] entity_sql = mock_prisma.db.query_raw.call_args_list[1][0][0] assert "entity_id" not in main_sql - assert '"team_id" AS entity_id' in entity_sql - assert '(date, "team_id"),' in entity_sql + assert "COALESCE(\"team_id\", '') AS entity_id" in entity_sql + assert "GROUP BY date, COALESCE(\"team_id\", '')" in entity_sql + assert '"team_id" AS entity_id' not in entity_sql assert result.metadata.total_spend == 18.0 + assert result.metadata.entity_total_api_keys == {"team-a": 3, "team-b": 2, "Unassigned": 1} assert len(result.results) == 1 daily = result.results[0] assert daily.metrics.spend == 18.0 entities = daily.breakdown.entities - assert set(entities) == {"team-a", "team-b"} + assert set(entities) == {"team-a", "team-b", "Unassigned"} assert entities["team-a"].metrics.spend == 12.0 assert entities["team-a"].metadata == {"team_alias": "Alpha"} assert entities["team-a"].api_key_breakdown["key-1"].metrics.spend == 12.0 - assert entities["team-b"].metrics.spend == 6.0 + assert entities["Unassigned"].api_key_breakdown["key-3"].metrics.spend == 2.0 + assert entities["team-b"].metrics.spend == 4.0 assert entities["team-b"].metadata == {} - assert entities["team-b"].api_key_breakdown["key-2"].metrics.spend == 6.0 + assert entities["team-b"].api_key_breakdown["key-2"].metrics.spend == 4.0 # Rollups with the entity bit set must still land in their usual buckets assert daily.breakdown.models["gpt-4o"].metrics.spend == 18.0 diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index d6be455a321..be8628d7f46 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -2574,19 +2574,18 @@ async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch, assert result is mock_response # Verify the helper was called with the right parameters - mock_get_daily_agg.assert_called_once_with( - prisma_client=mock_prisma_client, - table_name="litellm_dailyuserspend", - entity_id_field="user_id", - entity_id=None, # global view: no user_id filter - entity_metadata_field=None, - start_date="2025-02-01", - end_date="2025-02-28", - model="gpt-4", - api_key=None, - timezone_offset_minutes=480, - include_current_utc_day=include_current_utc_day, - ) + mock_get_daily_agg.assert_called_once() + repository, scope = mock_get_daily_agg.call_args.args + assert repository is not None + assert scope.table.value == "litellm_dailyuserspend" + assert scope.entity_id_field == "user_id" + assert scope.entity_ids is None + assert scope.start_date == "2025-02-01" + assert scope.end_date == "2025-02-28" + assert scope.model == "gpt-4" + assert scope.api_keys is None + assert scope.timezone_offset_minutes == 480 + assert scope.include_current_utc_day is include_current_utc_day @pytest.mark.asyncio @@ -2655,7 +2654,9 @@ async def test_get_user_daily_activity_aggregated_non_admin_cannot_view_other_us assert result is mock_response mock_get_daily_agg.assert_called_once() - assert mock_get_daily_agg.call_args.kwargs["entity_id"] == "regular-user-123" + repository, scope = mock_get_daily_agg.call_args.args + assert repository is not None + assert scope.entity_ids == ("regular-user-123",) @pytest.mark.asyncio diff --git a/tests/unit/proxy/management_endpoints/test_team_endpoints.py b/tests/unit/proxy/management_endpoints/test_team_endpoints.py index 6d56d325dc3..764193c9650 100644 --- a/tests/unit/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_team_endpoints.py @@ -14669,15 +14669,17 @@ async def test_get_team_daily_activity_aggregated_scopes_and_flags(mock_db_clien ) mock_aggregated.assert_called_once() - call_kwargs = mock_aggregated.call_args[1] - assert call_kwargs["api_key"] == ["user_key_1"] - assert call_kwargs["entity_id"] == [team_id] + repository, scope = mock_aggregated.call_args.args + call_kwargs = mock_aggregated.call_args.kwargs + assert repository is not None + assert scope.api_keys == ("user_key_1",) + assert scope.entity_ids == (team_id,) assert call_kwargs["entity_metadata_field"] == { team_id: {"team_alias": "Test Team"} } assert call_kwargs["include_entity_breakdown"] is True - assert call_kwargs["timezone_offset_minutes"] == 480 - assert call_kwargs["table_name"] == "litellm_dailyteamspend" + assert scope.timezone_offset_minutes == 480 + assert scope.table.value == "litellm_dailyteamspend" @pytest.mark.asyncio diff --git a/tests/unit/proxy/proxy_server/test_lifecycle.py b/tests/unit/proxy/proxy_server/test_lifecycle.py index 4047473e9d7..ba5501315d9 100644 --- a/tests/unit/proxy/proxy_server/test_lifecycle.py +++ b/tests/unit/proxy/proxy_server/test_lifecycle.py @@ -17,19 +17,16 @@ Pins covered: from __future__ import annotations -import asyncio import inspect import json import logging import os import subprocess from collections.abc import Awaitable, Callable -from typing import List, Optional, Union from unittest.mock import AsyncMock, MagicMock, patch import pytest from apscheduler.schedulers.asyncio import AsyncIOScheduler -from fastapi import FastAPI from pydantic import BaseModel from typing_extensions import TypedDict @@ -682,16 +679,16 @@ class _SampleTD(TypedDict): def test_resolve_typed_dict_type_finds_class_in_optional(): - typ = Optional[_SampleTD] + typ = _SampleTD | None result = _resolve_typed_dict_type(typ) observed = { - "input_repr": "Optional[_SampleTD]", + "input_repr": "_SampleTD | None", "result_is_sample_td": result is _SampleTD, "result_is_class": isinstance(result, type), } assert normalize(observed) == { - "input_repr": "Optional[_SampleTD]", + "input_repr": "_SampleTD | None", "result_is_sample_td": True, "result_is_class": True, } @@ -717,7 +714,7 @@ class _SampleModelB(BaseModel): def test_resolve_pydantic_type_extracts_non_none_args_from_union(): - typ = Union[_SampleModelA, _SampleModelB, None] + typ = _SampleModelA | _SampleModelB | None result = _resolve_pydantic_type(typ) observed = { diff --git a/tests/unit/repositories/test_daily_activity_repository.py b/tests/unit/repositories/test_daily_activity_repository.py new file mode 100644 index 00000000000..f0bbfd32d2c --- /dev/null +++ b/tests/unit/repositories/test_daily_activity_repository.py @@ -0,0 +1,549 @@ +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Final + +import pytest +from pydantic import ValidationError + +from litellm import constants +from litellm.repositories.daily_activity_repository import DailyActivityRepository +from litellm.repositories.daily_activity_sql import ( + ExportCursor, + build_cache_leakage_keys_sql, + build_entity_rollup_sql, + build_export_sql, + build_key_page_sql, + build_key_search_sql, + build_model_top_keys_sql, +) +from litellm.types.repositories.daily_activity import ( + DailyActivityScope, + DailyActivityTable, + ExportType, + KeyMetadataRow, + KeyPage, + KeySpendRow, + SpendLogsWindow, +) + + +@dataclass(frozen=True, slots=True) +class _FakeVerificationToken: + token: str + key_alias: str | None + team_id: str | None + user_id: str | None + metadata: object | None + + +@dataclass(frozen=True, slots=True) +class _FakeDeletedVerificationToken(_FakeVerificationToken): + deleted_at: datetime + + +def _scope( + *, + table: DailyActivityTable = DailyActivityTable.USER, + entity_ids: tuple[str, ...] | None = ("user-1",), + api_keys: tuple[str, ...] | None = None, + exclude_entity_ids: tuple[str, ...] = (), + model: str | None = None, +) -> DailyActivityScope: + entity_field: Final = { + DailyActivityTable.USER: "user_id", + DailyActivityTable.TEAM: "team_id", + DailyActivityTable.TAG: "tag", + DailyActivityTable.ORGANIZATION: "organization_id", + DailyActivityTable.CUSTOMER: "end_user_id", + DailyActivityTable.AGENT: "agent_id", + }[table] + return DailyActivityScope( + table=table, + entity_id_field=entity_field, + entity_ids=entity_ids, + exclude_entity_ids=exclude_entity_ids, + api_keys=api_keys, + start_date="2026-01-01", + end_date="2026-01-31", + model=model, + timezone_offset_minutes=None, + ) + + +def _key_spend_row(api_key: str) -> dict[str, object]: + return { + "api_key": api_key, + "spend": 1.0, + "prompt_tokens": 10, + "completion_tokens": 2, + "total_tokens": 12, + "api_requests": 1, + "successful_requests": 1, + "failed_requests": 0, + "cache_read_input_tokens": 3, + "cache_creation_input_tokens": 1, + } + + +def _export_row(api_key: str | None) -> dict[str, object]: + return { + "date": "2026-01-01", + "entity_id": "user-1", + "entity_alias": None, + "api_key": api_key, + "key_alias": None, + "user_id": None, + "user_email": None, + "model": None, + "spend": 1.0, + "flat_cost": 0.0, + "prompt_tokens": 10, + "completion_tokens": 2, + "api_requests": 1, + "successful_requests": 1, + "failed_requests": 0, + "cache_read_input_tokens": 3, + "cache_creation_input_tokens": 1, + } + + +class _FakeTable: + def __init__(self, rows: Sequence[object] = ()) -> None: + self.rows: Final = tuple(rows) + self.find_many_calls: list[Mapping[str, object]] = [] + self.count_calls: list[Mapping[str, object]] = [] + self.pagination_calls: list[tuple[int | None, int | None, tuple[Mapping[str, str], ...] | None]] = [] + + async def find_many( + self, + *, + where: Mapping[str, object], + skip: int | None = None, + take: int | None = None, + order: tuple[Mapping[str, str], ...] | None = None, + ) -> tuple[object, ...]: + self.find_many_calls.append(where) + self.pagination_calls.append((skip, take, order)) + if "token" not in where: + return self.rows + token_filter: Final = where["token"] + if not isinstance(token_filter, Mapping): + return () + token_values: Final = token_filter.get("in") + if not isinstance(token_values, list): + return () + return tuple(row for row in self.rows if isinstance(row, _FakeVerificationToken) and row.token in token_values) + + async def count(self, *, where: Mapping[str, object]) -> int: + self.count_calls.append(where) + return len(self.rows) + + +class _FailingTable(_FakeTable): + def __init__(self, failure: str) -> None: + super().__init__() + self.failure: Final = failure + + async def find_many( + self, + *, + where: Mapping[str, object], + skip: int | None = None, + take: int | None = None, + order: tuple[Mapping[str, str], ...] | None = None, + ) -> tuple[object, ...]: + raise RuntimeError(f"{self.failure}: {where!r} {skip!r} {take!r} {order!r}") + + +class _FakeDatabase: + def __init__(self, responses: Sequence[Sequence[Mapping[str, object]] | None] = ()) -> None: + self.responses = tuple(responses) + self.query_calls: list[tuple[str, tuple[object, ...]]] = [] + self.litellm_verificationtoken = _FakeTable() + self.litellm_deletedverificationtoken = _FakeTable() + self.litellm_dailyuserspend = _FakeTable() + self.litellm_dailyteamspend = _FakeTable() + self.litellm_dailytagspend = _FakeTable() + self.litellm_dailyorganizationspend = _FakeTable() + self.litellm_dailyenduserspend = _FakeTable() + self.litellm_dailyagentspend = _FakeTable() + + async def query_raw(self, query: str, *params: object) -> Sequence[Mapping[str, object]] | None: + self.query_calls.append((query, params)) + response_index: Final = len(self.query_calls) - 1 + if response_index >= len(self.responses): + return () + return self.responses[response_index] + + +class _FakePrismaClient: + def __init__(self, database: _FakeDatabase) -> None: + self.db: Final = database + + +class _ProxyReads: + def __init__(self) -> None: + self.recovery_calls: list[tuple[Mapping[str, KeyMetadataRow], frozenset[str], SpendLogsWindow | None]] = [] + + async def recover_key_metadata( + self, + resolved: Mapping[str, KeyMetadataRow], + api_keys: frozenset[str], + window: SpendLogsWindow | None, + ) -> Mapping[str, KeyMetadataRow]: + self.recovery_calls.append((resolved, api_keys, window)) + return resolved + + +def _repository( + database: _FakeDatabase, proxy_reads: _ProxyReads | None = None +) -> tuple[DailyActivityRepository, _ProxyReads]: + reads: Final = proxy_reads if proxy_reads is not None else _ProxyReads() + return DailyActivityRepository(_FakePrismaClient(database), proxy_reads=reads), reads + + +@pytest.mark.asyncio +async def test_key_methods_send_builder_queries_with_caller_limits() -> None: + database = _FakeDatabase(((_key_spend_row("key-a"),), (_key_spend_row("key-b"),), (_key_spend_row("key-c"),))) + repository, _ = _repository(database) + scope = _scope() + + assert await repository.search_keys(scope, search="key", limit=2) == ("key-a",) + model_keys: Final = await repository.model_top_keys(scope, model_group="model-a", by_model_group=True, limit=2) + leakage_keys: Final = await repository.cache_leakage_keys(scope, limit=2) + + assert tuple(row.api_key for row in model_keys) == ("key-b",) + assert tuple(row.api_key for row in leakage_keys) == ("key-c",) + assert model_keys[0].spend == 1.0 + assert leakage_keys[0].prompt_tokens - leakage_keys[0].cache_read_input_tokens == 7 + assert database.query_calls == [ + ( + build_key_search_sql(scope, search="key", limit=2).sql, + build_key_search_sql(scope, search="key", limit=2).params, + ), + ( + build_model_top_keys_sql(scope, model_group="model-a", by_model_group=True, limit=2).sql, + build_model_top_keys_sql(scope, model_group="model-a", by_model_group=True, limit=2).params, + ), + ( + build_cache_leakage_keys_sql(scope, limit=2).sql, + build_cache_leakage_keys_sql(scope, limit=2).params, + ), + ] + + +@pytest.mark.asyncio +async def test_key_page_maps_rows_and_keeps_total_for_an_empty_page() -> None: + database = _FakeDatabase( + ( + ({"total_api_keys": 2, **_key_spend_row("key-a")},), + ({"total_api_keys": 2, "api_key": None},), + ) + ) + repository, _ = _repository(database) + scope = _scope() + + first_page: Final = await repository.key_page(scope, offset=0, limit=1) + empty_page: Final = await repository.key_page(scope, offset=2, limit=1) + + assert first_page == KeyPage( + rows=( + KeySpendRow( + api_key="key-a", + spend=1.0, + prompt_tokens=10, + completion_tokens=2, + total_tokens=12, + api_requests=1, + successful_requests=1, + failed_requests=0, + cache_read_input_tokens=3, + cache_creation_input_tokens=1, + ), + ), + total_api_keys=2, + ) + assert empty_page == KeyPage(rows=(), total_api_keys=2) + assert database.query_calls == [ + ( + build_key_page_sql(scope, offset=0, limit=1).sql, + build_key_page_sql(scope, offset=0, limit=1).params, + ), + ( + build_key_page_sql(scope, offset=2, limit=1).sql, + build_key_page_sql(scope, offset=2, limit=1).params, + ), + ] + + +@pytest.mark.asyncio +async def test_key_methods_reject_limits_outside_bounds() -> None: + database = _FakeDatabase() + repository, _ = _repository(database) + + with pytest.raises(ValueError, match="limit"): + await repository.search_keys(_scope(), search="key", limit=0) + with pytest.raises(ValueError, match="limit"): + await repository.model_top_keys(_scope(), model_group="model-a", by_model_group=False, limit=0) + with pytest.raises(ValueError, match="limit"): + await repository.cache_leakage_keys(_scope(), limit=0) + with pytest.raises(ValueError, match="limit"): + await repository.search_keys(_scope(), search="key", limit=constants.USAGE_KEY_SEARCH_MAX + 1) + with pytest.raises(ValueError, match="limit"): + await repository.model_top_keys( + _scope(), model_group="model-a", by_model_group=False, limit=constants.USAGE_MODEL_TOP_KEYS_MAX + 1 + ) + with pytest.raises(ValueError, match="limit"): + await repository.cache_leakage_keys(_scope(), limit=constants.USAGE_CACHE_LEAKAGE_KEYS_MAX + 1) + assert database.query_calls == [] + + +@pytest.mark.asyncio +async def test_key_spend_validation_rejects_malformed_rows() -> None: + repository, _ = _repository(_FakeDatabase((({"api_key": "missing-metrics"},),))) + + with pytest.raises(ValidationError): + await repository.search_keys(_scope(), search="key", limit=1) + + +@pytest.mark.asyncio +async def test_key_metadata_prefers_active_rows_and_recovers_all_requested_keys() -> None: + database = _FakeDatabase() + active: Final = _FakeVerificationToken( + token="active", + key_alias="current", + team_id="team-active", + user_id="user-active", + metadata={"tags": ["production", "internal"]}, + ) + deleted_active_duplicate: Final = _FakeDeletedVerificationToken( + token="active", + key_alias="stale", + team_id="team-stale", + user_id="user-stale", + metadata={"tags": []}, + deleted_at=datetime(2026, 1, 3, tzinfo=timezone.utc), + ) + deleted_older: Final = _FakeDeletedVerificationToken( + token="deleted", + key_alias="older", + team_id=None, + user_id=None, + metadata={"tags": "invalid"}, + deleted_at=datetime(2026, 1, 2, tzinfo=timezone.utc), + ) + deleted_newer: Final = _FakeDeletedVerificationToken( + token="deleted", + key_alias="newer", + team_id=None, + user_id=None, + metadata={"tags": ["archived"]}, + deleted_at=datetime(2026, 1, 4, tzinfo=timezone.utc), + ) + malformed_non_list: Final = _FakeVerificationToken( + token="malformed-non-list", + key_alias=None, + team_id=None, + user_id=None, + metadata={"tags": "invalid"}, + ) + malformed_list: Final = _FakeVerificationToken( + token="malformed-list", + key_alias=None, + team_id=None, + user_id=None, + metadata={"tags": [1]}, + ) + database.litellm_verificationtoken = _FakeTable((active, malformed_non_list, malformed_list)) + database.litellm_deletedverificationtoken = _FakeTable((deleted_active_duplicate, deleted_older, deleted_newer)) + proxy_reads: Final = _ProxyReads() + repository, _ = _repository(database, proxy_reads) + window: Final = (datetime(2026, 1, 1), datetime(2026, 2, 1)) + requested: Final = frozenset(("active", "deleted", "malformed-non-list", "malformed-list", "unresolved")) + + result = await repository.key_metadata(requested, window) + + assert result["active"] == KeyMetadataRow( + api_key="active", + key_alias="current", + team_id="team-active", + user_id="user-active", + user_email=None, + key_exists=True, + tags=("production", "internal"), + ) + assert result["deleted"].key_alias == "newer" + assert result["deleted"].key_exists is False + assert result["deleted"].tags == ("archived",) + assert result["malformed-non-list"].tags == () + assert result["malformed-list"].tags == () + assert len(database.litellm_deletedverificationtoken.find_many_calls) == 1 + assert set(database.litellm_deletedverificationtoken.find_many_calls[0]["token"]["in"]) == { + "deleted", + "unresolved", + } + assert proxy_reads.recovery_calls == [ + ( + result, + requested, + window, + ) + ] + + +@pytest.mark.asyncio +async def test_key_metadata_continues_with_active_rows_when_deleted_lookup_fails() -> None: + database = _FakeDatabase() + active: Final = _FakeVerificationToken( + token="active", + key_alias="current", + team_id=None, + user_id=None, + metadata={"tags": []}, + ) + database.litellm_verificationtoken = _FakeTable((active,)) + database.litellm_deletedverificationtoken = _FailingTable("deleted token query failed") + repository, proxy_reads = _repository(database) + + result = await repository.key_metadata(frozenset(("active", "deleted")), None) + + assert result["active"].key_alias == "current" + assert tuple(proxy_reads.recovery_calls[0][0]) == ("active",) + assert proxy_reads.recovery_calls[0][1] == frozenset(("active", "deleted")) + + +@pytest.mark.asyncio +async def test_key_metadata_empty_set_does_not_query_tables() -> None: + database = _FakeDatabase() + repository, proxy_reads = _repository(database) + + assert await repository.key_metadata(frozenset(), None) == {} + assert database.litellm_verificationtoken.find_many_calls == [] + assert proxy_reads.recovery_calls == [] + + +@pytest.mark.asyncio +async def test_key_metadata_propagates_active_token_lookup_failures() -> None: + database = _FakeDatabase() + database.litellm_verificationtoken = _FailingTable("active token query failed") + repository, _ = _repository(database) + + with pytest.raises(RuntimeError, match="active token query failed"): + await repository.key_metadata(frozenset(("active",)), None) + + assert database.litellm_deletedverificationtoken.find_many_calls == [] + + +@pytest.mark.asyncio +async def test_aggregated_normalizes_a_null_raw_query_result() -> None: + database = _FakeDatabase((None,)) + repository, _ = _repository(database) + + result = await repository.aggregated( + _scope(), include_entity_breakdown=False, api_key_limit=constants.USAGE_TOP_API_KEYS_DEFAULT + ) + + assert result.grouping_rows == () + assert result.entity_rows is None + assert result.distinct_api_keys == 0 + assert len(database.query_calls) == 1 + + +@pytest.mark.asyncio +async def test_aggregated_passes_api_key_limit_to_entity_rollup_query() -> None: + database = _FakeDatabase(((), ())) + repository, _ = _repository(database) + scope = _scope(table=DailyActivityTable.TEAM) + + result = await repository.aggregated(scope, include_entity_breakdown=True, api_key_limit=3) + + assert result.entity_rows == () + assert database.query_calls[1] == ( + build_entity_rollup_sql(scope, api_key_limit=3).sql, + build_entity_rollup_sql(scope, api_key_limit=3).params, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("table", "entity_field"), + [ + (DailyActivityTable.USER, "user_id"), + (DailyActivityTable.TEAM, "team_id"), + (DailyActivityTable.TAG, "tag"), + (DailyActivityTable.ORGANIZATION, "organization_id"), + (DailyActivityTable.CUSTOMER, "end_user_id"), + (DailyActivityTable.AGENT, "agent_id"), + ], +) +async def test_daily_rows_selects_the_table_and_applies_filters_and_pagination( + table: DailyActivityTable, entity_field: str +) -> None: + database = _FakeDatabase() + repository, _ = _repository(database) + scope = _scope( + table=table, + entity_ids=("entity-1",), + exclude_entity_ids=("excluded-1",), + api_keys=("key-1",), + model="model-1", + ) + + result = await repository.daily_rows(scope, page=3, page_size=2) + + expected_where: Final = { + "date": {"gte": "2026-01-01", "lte": "2026-01-31"}, + entity_field: {"in": ["entity-1"], "not": {"in": ["excluded-1"]}}, + "model": "model-1", + "api_key": {"in": ["key-1"]}, + } + tables: Final = { + DailyActivityTable.USER: database.litellm_dailyuserspend, + DailyActivityTable.TEAM: database.litellm_dailyteamspend, + DailyActivityTable.TAG: database.litellm_dailytagspend, + DailyActivityTable.ORGANIZATION: database.litellm_dailyorganizationspend, + DailyActivityTable.CUSTOMER: database.litellm_dailyenduserspend, + DailyActivityTable.AGENT: database.litellm_dailyagentspend, + } + selected_table: Final = tables[table] + + assert result.total_count == 0 + assert result.rows == () + assert selected_table.count_calls == [expected_where] + assert selected_table.find_many_calls == [expected_where] + assert selected_table.pagination_calls == [(4, 2, ({"date": "desc"}, {"id": "asc"}))] + assert sum(len(daily_table.find_many_calls) for daily_table in tables.values()) == 1 + + +@pytest.mark.asyncio +async def test_export_is_lazy_and_uses_the_last_row_as_the_next_cursor(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(constants, "USAGE_EXPORT_BATCH_SIZE", 2) + database = _FakeDatabase( + ( + (_export_row("key-1"), _export_row("key-2")), + (_export_row("key-3"), _export_row("key-4")), + (_export_row("key-5"),), + ) + ) + repository, _ = _repository(database) + rows = repository.export_rows(_scope(), export_type=ExportType.DAILY_WITH_KEYS) + + assert database.query_calls == [] + assert (await rows.__anext__()).api_key == "key-1" + assert len(database.query_calls) == 1 + results = [row async for row in rows] + + assert [row.api_key for row in results] == ["key-2", "key-3", "key-4", "key-5"] + assert len(database.query_calls) == 3 + assert database.query_calls[1][1][-4:] == ("2026-01-01", "user-1", "key-2", 2) + assert database.query_calls[2][1][-4:] == ("2026-01-01", "user-1", "key-4", 2) + assert ( + build_export_sql( + _scope(), + export_type=ExportType.DAILY_WITH_KEYS, + after=ExportCursor("2026-01-01", "user-1", "key-2"), + batch_size=2, + ).params + == database.query_calls[1][1] + ) diff --git a/tests/unit/repositories/test_daily_activity_sql.py b/tests/unit/repositories/test_daily_activity_sql.py new file mode 100644 index 00000000000..67f15712b11 --- /dev/null +++ b/tests/unit/repositories/test_daily_activity_sql.py @@ -0,0 +1,411 @@ +from datetime import datetime, timezone +from typing import Final + +import pytest + +from litellm import constants +from litellm.constants import PTU_SENTINEL_API_KEY +from litellm.repositories.daily_activity_sql import ( + ExportCursor, + adjust_dates_for_timezone, + build_aggregated_sql, + build_cache_leakage_keys_sql, + build_entity_rollup_sql, + build_export_sql, + build_key_page_sql, + build_key_search_sql, + build_model_top_keys_sql, + build_where_clause, +) +from litellm.types.proxy.management_endpoints.common_daily_activity import SpendMetrics +from litellm.types.repositories.daily_activity import DailyActivityScope, DailyActivityTable, ExportType + + +def _scope( + *, + table: DailyActivityTable = DailyActivityTable.USER, + entity_ids: tuple[str, ...] | None = ("user-1",), + exclude_entity_ids: tuple[str, ...] = (), + api_keys: tuple[str, ...] | None = None, + model: str | None = None, + timezone_offset_minutes: int | None = None, + include_current_utc_day: bool = False, + start_date: str = "2026-01-01", + end_date: str = "2026-01-31", +) -> DailyActivityScope: + entity_field = { + DailyActivityTable.USER: "user_id", + DailyActivityTable.TEAM: "team_id", + DailyActivityTable.TAG: "tag", + DailyActivityTable.ORGANIZATION: "organization_id", + DailyActivityTable.CUSTOMER: "end_user_id", + DailyActivityTable.AGENT: "agent_id", + }[table] + return DailyActivityScope( + table=table, + entity_id_field=entity_field, + entity_ids=entity_ids, + exclude_entity_ids=exclude_entity_ids, + api_keys=api_keys, + start_date=start_date, + end_date=end_date, + model=model, + timezone_offset_minutes=timezone_offset_minutes, + include_current_utc_day=include_current_utc_day, + ) + + +def test_where_clause_binds_each_filter_as_a_single_array_parameter() -> None: + scope = _scope( + entity_ids=("user-1", "user-2"), + exclude_entity_ids=("user-3",), + api_keys=("key-1", "key-2"), + model="gpt-test", + ) + + sql, params = build_where_clause(scope) + + assert sql == ( + 'date >= $1 AND date <= $2 AND "user_id" = ANY($3::text[]) ' + 'AND NOT ("user_id" = ANY($4::text[])) AND model = $5 AND api_key = ANY($6::text[])' + ) + assert params == ( + "2026-01-01", + "2026-01-31", + ["user-1", "user-2"], + ["user-3"], + "gpt-test", + ["key-1", "key-2"], + ) + + +@pytest.mark.parametrize( + ("entity_ids", "api_keys", "expected_sql", "expected_params"), + [ + (None, None, "date >= $1 AND date <= $2", ("2026-01-01", "2026-01-31")), + ((), None, "date >= $1 AND date <= $2 AND FALSE", ("2026-01-01", "2026-01-31")), + (None, (), "date >= $1 AND date <= $2 AND FALSE", ("2026-01-01", "2026-01-31")), + ], +) +def test_where_clause_distinguishes_no_filter_from_empty_membership( + entity_ids: tuple[str, ...] | None, + api_keys: tuple[str, ...] | None, + expected_sql: str, + expected_params: tuple[object, ...], +) -> None: + scope = _scope(entity_ids=entity_ids, api_keys=api_keys) + + sql, params = build_where_clause(scope) + + assert sql == expected_sql + assert params == expected_params + + +def test_key_page_sql_orders_exact_spend_and_binds_scope_before_page() -> None: + query = build_key_page_sql(_scope(), offset=7, limit=3) + + assert query.params == ( + "2026-01-01", + "2026-01-31", + ["user-1"], + PTU_SENTINEL_API_KEY, + 3, + 7, + ) + assert "SUM(spend::numeric) AS rank_spend" in query.sql + assert "ORDER BY rank_spend DESC, api_key" in query.sql + assert "(SELECT COUNT(*) FROM ranked)::bigint AS total_api_keys" in query.sql + + +@pytest.mark.parametrize( + ("offset", "limit", "error"), + ( + (0, 0, "limit must be between"), + (0, constants.USAGE_KEY_PAGE_MAX + 1, "limit must be between"), + (-1, 1, "offset must be non-negative"), + ), +) +def test_key_page_sql_rejects_invalid_page_bounds(offset: int, limit: int, error: str) -> None: + with pytest.raises(ValueError, match=error): + build_key_page_sql(_scope(), offset=offset, limit=limit) + + +def test_scope_rejects_an_entity_field_not_allowed_for_its_table() -> None: + with pytest.raises(ValueError, match="Invalid entity_id_field"): + DailyActivityScope( + table=DailyActivityTable.USER, + entity_id_field="team_id", + entity_ids=None, + exclude_entity_ids=(), + api_keys=None, + start_date="2026-01-01", + end_date="2026-01-31", + model=None, + timezone_offset_minutes=None, + ) + + +def test_timezone_adjustment_only_extends_an_opted_in_live_range() -> None: + now = datetime(2026, 8, 6, 4, 30, tzinfo=timezone.utc) + + assert adjust_dates_for_timezone("2026-07-06", "2026-08-05", 420, include_current_utc_day=True, utc_now=now) == ( + "2026-07-06", + "2026-08-06", + ) + assert adjust_dates_for_timezone("2026-07-01", "2026-08-04", 420, include_current_utc_day=True, utc_now=now) == ( + "2026-07-01", + "2026-08-04", + ) + + +@pytest.mark.parametrize("offset_minutes", [None, 0, -330, -540, -60, 240, 300, 480]) +def test_timezone_adjustment_preserves_daily_bucket_dates(offset_minutes: int | None) -> None: + assert adjust_dates_for_timezone("2026-05-29", "2026-05-29", offset_minutes) == ( + "2026-05-29", + "2026-05-29", + ) + + +@pytest.mark.parametrize("offset_minutes", [-330, 480]) +def test_timezone_adjustment_preserves_single_day_additivity(offset_minutes: int) -> None: + days: Final = ("2026-05-29", "2026-05-30", "2026-05-31", "2026-06-01", "2026-06-02") + single_day_ranges: Final = tuple(adjust_dates_for_timezone(day, day, offset_minutes) for day in days) + multi_day_range: Final = adjust_dates_for_timezone(days[0], days[-1], offset_minutes) + + assert tuple(start for start, _ in single_day_ranges) == days + assert tuple(end for _, end in single_day_ranges) == days + assert (min(start for start, _ in single_day_ranges), max(end for _, end in single_day_ranges)) == multi_day_range + + +def test_timezone_adjustment_live_end_handles_offset_and_opt_in_cases() -> None: + pt_evening: Final = datetime(2026, 8, 6, 4, 30, tzinfo=timezone.utc) + ist_evening: Final = datetime(2026, 8, 5, 17, 0, tzinfo=timezone.utc) + utc_noon: Final = datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) + + assert adjust_dates_for_timezone( + "2026-07-06", "2026-08-05", 420, include_current_utc_day=True, utc_now=pt_evening + ) == ("2026-07-06", "2026-08-06") + assert adjust_dates_for_timezone("2026-07-06", "2026-08-05", 420, utc_now=pt_evening) == ( + "2026-07-06", + "2026-08-05", + ) + assert adjust_dates_for_timezone( + "2026-07-01", "2026-08-04", 420, include_current_utc_day=True, utc_now=pt_evening + ) == ("2026-07-01", "2026-08-04") + assert adjust_dates_for_timezone( + "2026-07-07", "2026-08-06", -330, include_current_utc_day=True, utc_now=ist_evening + ) == ("2026-07-07", "2026-08-06") + assert adjust_dates_for_timezone( + "2026-07-06", "2026-08-05", None, include_current_utc_day=True, utc_now=pt_evening + ) == ("2026-07-06", "2026-08-05") + assert adjust_dates_for_timezone("2026-07-06", "2026-08-05", 0, include_current_utc_day=True, utc_now=utc_noon) == ( + "2026-07-06", + "2026-08-05", + ) + assert adjust_dates_for_timezone( + "2026-07-06", "2026-08-09", 420, include_current_utc_day=True, utc_now=pt_evening + ) == ("2026-07-06", "2026-08-09") + + +@pytest.mark.parametrize("offset_minutes", [None, 0, -330, 480]) +def test_aggregated_query_uses_the_caller_date_bounds(offset_minutes: int | None) -> None: + query = build_aggregated_sql( + _scope( + timezone_offset_minutes=offset_minutes, + start_date="2026-05-29", + end_date="2026-05-29", + ), + api_key_limit=constants.USAGE_TOP_API_KEYS_DEFAULT, + ) + + assert query.params[:2] == ("2026-05-29", "2026-05-29") + assert "date >= $1" in query.sql + assert "date <= $2" in query.sql + + +def test_aggregate_query_sums_all_savings_drivers_and_response_time() -> None: + query = build_aggregated_sql(_scope(), api_key_limit=constants.USAGE_TOP_API_KEYS_DEFAULT) + fields: Final = tuple(field for field in SpendMetrics.model_fields if field.endswith("_savings_spend")) + ( + "total_response_time_ms", + "timed_requests", + ) + + assert fields + assert all(f"SUM({field})" in query.sql for field in fields) + + +def test_aggregated_query_binds_sentinel_and_api_key_limit_after_scope_values() -> None: + scope = _scope(entity_ids=None, api_keys=("key-1",)) + + query = build_aggregated_sql(scope, api_key_limit=3) + + assert "api_key <> $4" in query.sql + assert "LIMIT $5" in query.sql + assert 'FROM "LiteLLM_DailyUserSpend"' in query.sql + assert query.params == ( + "2026-01-01", + "2026-01-31", + ["key-1"], + PTU_SENTINEL_API_KEY, + 3, + ) + + +@pytest.mark.parametrize("api_key_limit", [0, constants.USAGE_TOP_API_KEYS_MAX + 1]) +def test_aggregated_query_rejects_api_key_limits_outside_bounds(api_key_limit: int) -> None: + with pytest.raises(ValueError, match="api_key_limit"): + build_aggregated_sql(_scope(), api_key_limit=api_key_limit) + + +def test_entity_rollup_bounds_keys_and_reuses_scope_filters() -> None: + query = build_entity_rollup_sql( + _scope(table=DailyActivityTable.TEAM, entity_ids=None, api_keys=("key-1", "key-2")), + api_key_limit=3, + ) + + assert query.sql.count("COALESCE(\"team_id\", '') AS entity_id") == 3 + assert query.sql.count("GROUP BY date, COALESCE(\"team_id\", '')") == 2 + assert '"team_id" AS entity_id' not in query.sql + assert "JOIN top_api_keys USING (api_key)" in query.sql + assert "api_key = ANY($3::text[])" in query.sql + assert query.sql.count("api_key = ANY($3::text[])") == 4 + assert query.sql.count("api_key <> $4") == 2 + assert query.sql.count("ORDER BY SUM(spend::numeric) DESC, api_key") == 1 + assert "k.entity_id = e.entity_id" in query.sql + assert "LIMIT $5" in query.sql + assert query.params == ("2026-01-01", "2026-01-31", ["key-1", "key-2"], PTU_SENTINEL_API_KEY, 3) + + +@pytest.mark.parametrize("api_key_limit", [0, constants.USAGE_TOP_API_KEYS_MAX + 1]) +def test_entity_rollup_rejects_api_key_limits_outside_bounds(api_key_limit: int) -> None: + with pytest.raises(ValueError, match="api_key_limit"): + build_entity_rollup_sql(_scope(), api_key_limit=api_key_limit) + + +def test_search_query_escapes_pattern_metacharacters_and_binds_limit() -> None: + query = build_key_search_sql(_scope(entity_ids=None), search=r"foo%_\bar", limit=4) + + assert "OR api_key IN (" in query.sql + assert 'SELECT v.token FROM "LiteLLM_VerificationToken" v' in query.sql + assert 'LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = v.user_id' in query.sql + assert 'SELECT d.token FROM "LiteLLM_DeletedVerificationToken" d' in query.sql + assert 'LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = d.user_id' in query.sql + assert "d.key_alias ILIKE $3 ESCAPE" in query.sql + assert "d.user_id ILIKE $3 ESCAPE" in query.sql + assert "api_key ILIKE $3 ESCAPE" in query.sql + assert "v.key_alias ILIKE $3 ESCAPE" in query.sql + assert "v.user_id ILIKE $3 ESCAPE" in query.sql + assert "u.user_email ILIKE $3 ESCAPE" in query.sql + assert query.sql.count("ILIKE $3 ESCAPE") == 7 + assert "api_key <> $4" in query.sql + assert "ORDER BY SUM(spend::numeric) DESC, api_key" in query.sql + assert "LIMIT $5" in query.sql + assert query.params == ( + "2026-01-01", + "2026-01-31", + r"%foo\%\_\\bar%", + PTU_SENTINEL_API_KEY, + 4, + ) + + +def test_model_and_cache_key_queries_bind_filters_sentinel_and_limits() -> None: + model_query = build_model_top_keys_sql( + _scope(entity_ids=None), model_group="public-model", by_model_group=True, limit=5 + ) + leakage_query = build_cache_leakage_keys_sql(_scope(entity_ids=None), limit=20) + + assert "COALESCE(NULLIF(model_group, ''), model) = $3" in model_query.sql + assert "api_key <> $4" in model_query.sql + assert "ORDER BY SUM(spend::numeric) DESC, api_key" in model_query.sql + assert model_query.params == ("2026-01-01", "2026-01-31", "public-model", PTU_SENTINEL_API_KEY, 5) + assert "HAVING SUM(prompt_tokens) - SUM(cache_read_input_tokens) > 0" in leakage_query.sql + assert "ORDER BY SUM(prompt_tokens) - SUM(cache_read_input_tokens) DESC, api_key" in leakage_query.sql + assert leakage_query.params == ("2026-01-01", "2026-01-31", PTU_SENTINEL_API_KEY, 20) + + +@pytest.mark.parametrize( + "builder", + [ + lambda: build_key_search_sql(_scope(), search="x", limit=0), + lambda: build_model_top_keys_sql(_scope(), model_group="x", by_model_group=False, limit=0), + lambda: build_cache_leakage_keys_sql(_scope(), limit=0), + lambda: build_export_sql(_scope(), export_type=ExportType.DAILY, after=None, batch_size=0), + ], +) +def test_query_builders_reject_nonpositive_limits(builder) -> None: + with pytest.raises(ValueError, match="limit must be at least 1"): + builder() + + +@pytest.mark.parametrize( + ("export_type", "group_key", "key_filter", "joins"), + [ + (ExportType.DAILY, "''", "", ""), + (ExportType.DAILY_WITH_KEYS, "scoped.api_key", "api_key <> $3", 'LEFT JOIN "LiteLLM_VerificationToken"'), + (ExportType.DAILY_WITH_MODELS, "COALESCE(scoped.model, '')", "api_key <> $3", ""), + ( + ExportType.DAILY_WITH_USERS, + "COALESCE(vt.user_id, dvt.user_id, '')", + "api_key <> $3", + 'LEFT JOIN "LiteLLM_VerificationToken"', + ), + ], +) +def test_export_groups_by_requested_key_and_binds_cursor_after_scope( + export_type: ExportType, group_key: str, key_filter: str, joins: str +) -> None: + query = build_export_sql( + _scope(entity_ids=None), + export_type=export_type, + after=ExportCursor(date="2026-01-12", entity_id="user-2", group_key="group-3"), + batch_size=2, + ) + + assert group_key in query.sql + assert key_filter in query.sql + assert joins in query.sql + assert "(scoped.date, COALESCE(scoped.\"user_id\", '')," in query.sql + order_keys: Final = ( + "scoped.date, COALESCE(scoped.\"user_id\", '')", + *((group_key,) if export_type is not ExportType.DAILY else ()), + ) + assert f"ORDER BY {', '.join(order_keys)}" in query.sql + expected_limit_index: Final = "$6" if export_type is ExportType.DAILY else "$7" + assert f"LIMIT {expected_limit_index}" in query.sql + assert query.params == ( + "2026-01-01", + "2026-01-31", + *((PTU_SENTINEL_API_KEY,) if export_type is not ExportType.DAILY else ()), + "2026-01-12", + "user-2", + "group-3", + 2, + ) + + +@pytest.mark.parametrize("export_type", [ExportType.DAILY_WITH_KEYS, ExportType.DAILY_WITH_USERS]) +def test_export_uses_latest_deleted_key_metadata(export_type: ExportType) -> None: + query = build_export_sql(_scope(entity_ids=None), export_type=export_type, after=None, batch_size=2) + + assert 'FROM "LiteLLM_DeletedVerificationToken"' in query.sql + assert "ORDER BY deleted_at DESC" in query.sql + assert "COALESCE(vt.user_id, dvt.user_id)" in query.sql + + +@pytest.mark.parametrize("export_type", tuple(ExportType)) +def test_export_without_cursor_omits_cursor_predicate_and_parameters(export_type: ExportType) -> None: + query = build_export_sql( + _scope(entity_ids=None), + export_type=export_type, + after=None, + batch_size=2, + ) + + assert "WHERE TRUE AND (scoped.date" not in query.sql + assert query.params == ( + "2026-01-01", + "2026-01-31", + *((PTU_SENTINEL_API_KEY,) if export_type is not ExportType.DAILY else ()), + 2, + ) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index ebc6d0e70cc..7bce039cd3e 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -30182,6 +30182,18 @@ export interface components { }; /** DailySpendMetadata */ DailySpendMetadata: { + /** + * Api Key Limit + * @description When set, api_keys and every api_key_breakdown list at most this many keys, ranked by spend. Totals and the model, provider, mcp and endpoint rollups still cover every key. + */ + api_key_limit?: number | null; + /** + * Entity Total Api Keys + * @description Distinct API keys per entity over the requested range, set when the entity breakdown is included. When an entity's count exceeds api_key_limit, its api_key_breakdown lists only its keys among the top api_key_limit keys overall. + */ + entity_total_api_keys?: { + [key: string]: number; + } | null; /** * Has More * @default false @@ -30192,6 +30204,11 @@ export interface components { * @default 1 */ page: number; + /** + * Total Api Keys + * @description Distinct API keys matching the filters. When this exceeds api_key_limit, the per-key lists are truncated to the highest-spend keys. + */ + total_api_keys?: number | null; /** * Total Api Requests * @default 0