refactor(repositories): daily activity repository with centralized bounded usage queries (#43398)

Co-authored-by: yassin <yassin@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-01 16:02:01 -07:00 • committed by GitHub
parent 4b1d9bf148
commit aa601ce4e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 3736 additions and 1173 deletions

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

View file

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

View file

@ -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 <module> raw-sql classifier_type.IN `IN ({_LLM_CLASSIFIER_TYPES_SQL})` 0

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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