mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-12 03:48:56 +00:00
Merge pull request #41293 from BerriAI/litellm_usage_key_free_aggregate_split
perf(proxy): split aggregated usage query into key-free rollups and bounded top-N keys
This commit is contained in:
commit
2e46b10320
17 changed files with 696 additions and 211 deletions
|
|
@ -2049,6 +2049,7 @@ MCP_SPEND_LOG_MODEL_PREFIX: Final[str] = "MCP: "
|
||||||
PTU_SENTINEL_API_KEY: Final[str] = "__ptu_flat_cost__"
|
PTU_SENTINEL_API_KEY: Final[str] = "__ptu_flat_cost__"
|
||||||
PTU_ROLLUP_JOB_ID: Final[str] = "ptu_flat_cost_rollup_job"
|
PTU_ROLLUP_JOB_ID: Final[str] = "ptu_flat_cost_rollup_job"
|
||||||
PTU_ROLLUP_LOCK_TTL_SECONDS: Final[int] = 900
|
PTU_ROLLUP_LOCK_TTL_SECONDS: Final[int] = 900
|
||||||
|
USAGE_TOP_API_KEYS_LIMIT: Final[int] = int(os.getenv("USAGE_TOP_API_KEYS_LIMIT", "100"))
|
||||||
# Furthest back the catch-up pass looks for unpriced PTU days when a deployment
|
# 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.
|
# declares no ptu_effective_from, bounding the scan for an open-ended window.
|
||||||
PTU_ROLLUP_MAX_BACKFILL_DAYS: Final[int] = 90
|
PTU_ROLLUP_MAX_BACKFILL_DAYS: Final[int] = 90
|
||||||
|
|
|
||||||
|
|
@ -3050,6 +3050,18 @@
|
||||||
},
|
},
|
||||||
"DailySpendMetadata": {
|
"DailySpendMetadata": {
|
||||||
"properties": {
|
"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"
|
||||||
|
},
|
||||||
"has_more": {
|
"has_more": {
|
||||||
"default": false,
|
"default": false,
|
||||||
"title": "Has More",
|
"title": "Has More",
|
||||||
|
|
@ -3060,6 +3072,18 @@
|
||||||
"title": "Page",
|
"title": "Page",
|
||||||
"type": "integer"
|
"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": {
|
"total_api_requests": {
|
||||||
"default": 0,
|
"default": 0,
|
||||||
"title": "Total Api Requests",
|
"title": "Total Api Requests",
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from fastapi import HTTPException, status
|
||||||
from typing_extensions import ReadOnly, TypedDict
|
from typing_extensions import ReadOnly, TypedDict
|
||||||
|
|
||||||
from litellm._logging import verbose_proxy_logger
|
from litellm._logging import verbose_proxy_logger
|
||||||
from litellm.constants import PTU_SENTINEL_API_KEY
|
from litellm.constants import PTU_SENTINEL_API_KEY, USAGE_TOP_API_KEYS_LIMIT
|
||||||
from litellm.proxy._types import CommonProxyErrors
|
from litellm.proxy._types import CommonProxyErrors
|
||||||
from litellm.proxy.spend_tracking.key_metadata_recovery import (
|
from litellm.proxy.spend_tracking.key_metadata_recovery import (
|
||||||
attach_user_emails,
|
attach_user_emails,
|
||||||
|
|
@ -146,15 +146,9 @@ class _AggregatedSpendData(TypedDict):
|
||||||
totals: SpendMetrics
|
totals: SpendMetrics
|
||||||
|
|
||||||
|
|
||||||
class _GroupingSetsRow(SimpleNamespace):
|
class _RollupMetricsRow(SimpleNamespace):
|
||||||
date: str
|
date: str
|
||||||
api_key: str | None
|
api_key: str | None
|
||||||
model: str | None
|
|
||||||
model_group: str | None
|
|
||||||
custom_llm_provider: str | None
|
|
||||||
mcp_namespaced_tool_name: str | None
|
|
||||||
endpoint: str | None
|
|
||||||
group_level: int
|
|
||||||
spend: float | None
|
spend: float | None
|
||||||
prompt_tokens: int | None
|
prompt_tokens: int | None
|
||||||
completion_tokens: int | None
|
completion_tokens: int | None
|
||||||
|
|
@ -172,12 +166,46 @@ class _GroupingSetsRow(SimpleNamespace):
|
||||||
timed_requests: int | None
|
timed_requests: int | None
|
||||||
|
|
||||||
|
|
||||||
class _EntityRollupRow(_GroupingSetsRow):
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class _EntityRollupRow(_RollupMetricsRow):
|
||||||
entity_id: str | None
|
entity_id: str | None
|
||||||
api_key_rolled: int
|
api_key_rolled: int
|
||||||
|
|
||||||
|
|
||||||
def _reported_flat_cost(record: DailySpendRecord | _GroupingSetsRow) -> float:
|
class _AggregatedQueryKwargs(TypedDict):
|
||||||
|
table_name: ReadOnly[str]
|
||||||
|
entity_id_field: ReadOnly[str]
|
||||||
|
entity_id: ReadOnly[str | list[str] | None]
|
||||||
|
start_date: ReadOnly[str]
|
||||||
|
end_date: ReadOnly[str]
|
||||||
|
model: ReadOnly[str | None]
|
||||||
|
api_key: ReadOnly[str | list[str] | None]
|
||||||
|
exclude_entity_ids: ReadOnly[list[str] | None]
|
||||||
|
timezone_offset_minutes: ReadOnly[int | None]
|
||||||
|
include_current_utc_day: ReadOnly[bool]
|
||||||
|
|
||||||
|
|
||||||
|
_SqlQuery = tuple[str, list[str]]
|
||||||
|
|
||||||
|
|
||||||
|
async def _query_raw_optional(
|
||||||
|
prisma_client: PrismaClient, query: _SqlQuery | None
|
||||||
|
) -> list[dict[str, object]] | None: # mutable-ok: prisma query_raw return shape
|
||||||
|
if query is None:
|
||||||
|
return None
|
||||||
|
return await prisma_client.db.query_raw(query[0], *query[1])
|
||||||
|
|
||||||
|
|
||||||
|
def _reported_flat_cost(record: DailySpendRecord | _RollupMetricsRow) -> float:
|
||||||
"""Flat cost a daily row reports, which is zero unless PTU cost attribution is enabled.
|
"""Flat cost a daily row reports, which is zero unless PTU cost attribution is enabled.
|
||||||
|
|
||||||
Both read paths funnel through here: the paginated path reads the ``ptu_flat_cost``
|
Both read paths funnel through here: the paginated path reads the ``ptu_flat_cost``
|
||||||
|
|
@ -699,71 +727,8 @@ def _ptu_flat_cost_select(table_name: str) -> str:
|
||||||
return "0::float AS ptu_flat_cost"
|
return "0::float AS ptu_flat_cost"
|
||||||
|
|
||||||
|
|
||||||
def _build_aggregated_sql_query(
|
def _rollup_metric_select(table_name: str) -> str:
|
||||||
*,
|
return f"""
|
||||||
table_name: str,
|
|
||||||
entity_id_field: str,
|
|
||||||
entity_id: str | list[str] | None, # mutable-ok: filter union shared with the paginated path
|
|
||||||
start_date: str,
|
|
||||||
end_date: str,
|
|
||||||
model: str | None,
|
|
||||||
api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path
|
|
||||||
exclude_entity_ids: list[str] | None = None, # mutable-ok: filter union shared with the paginated path
|
|
||||||
timezone_offset_minutes: int | None = None,
|
|
||||||
include_current_utc_day: bool = False,
|
|
||||||
) -> tuple[str, list[str]]: # mutable-ok: SQL text plus its ordered $N params
|
|
||||||
"""Build a parameterized SQL GROUP BY query for aggregated daily activity.
|
|
||||||
|
|
||||||
Groups by (date, api_key, model, model_group, custom_llm_provider,
|
|
||||||
mcp_namespaced_tool_name, endpoint) with SUMs on all metric columns.
|
|
||||||
The entity_id column is intentionally omitted from GROUP BY to collapse
|
|
||||||
rows across entities — this is where the biggest row reduction comes from.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tuple of (sql_query, params_list) ready for prisma_client.db.query_raw().
|
|
||||||
"""
|
|
||||||
pg_table: Final = _PRISMA_TO_PG_TABLE.get(table_name)
|
|
||||||
if pg_table is None:
|
|
||||||
raise ValueError(f"Unknown table name: {table_name}")
|
|
||||||
|
|
||||||
adjusted_start, adjusted_end = _adjust_dates_for_timezone(
|
|
||||||
start_date, end_date, timezone_offset_minutes, include_current_utc_day
|
|
||||||
)
|
|
||||||
|
|
||||||
where_clause, sql_params = _build_aggregated_where_clause(
|
|
||||||
entity_id_field=entity_id_field,
|
|
||||||
entity_id=entity_id,
|
|
||||||
adjusted_start=adjusted_start,
|
|
||||||
adjusted_end=adjusted_end,
|
|
||||||
model=model,
|
|
||||||
api_key=api_key,
|
|
||||||
exclude_entity_ids=exclude_entity_ids,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Postgres computes every rollup level the response needs — per-date
|
|
||||||
# totals, per-(date, model), per-(date, model, api_key), per-provider,
|
|
||||||
# etc. — in a single pass via GROUPING SETS. The GROUPING() bitmask
|
|
||||||
# encodes which level a row belongs to so Python can dispatch rows
|
|
||||||
# straight into their buckets without re-summing. The leaf grouping
|
|
||||||
# is omitted on purpose: nothing in the response shape needs it once
|
|
||||||
# all the rollups are present.
|
|
||||||
#
|
|
||||||
# TODO: drop the successful_requests/failed_requests aggregates (and the
|
|
||||||
# total_successful_requests metadata they feed) once the admin UI reads SGR
|
|
||||||
# only from LiteLLM_DailyGatewayRequests. The remaining spend, token and
|
|
||||||
# api_requests rollups are still served from here.
|
|
||||||
sql_query: Final = f"""
|
|
||||||
SELECT
|
|
||||||
date,
|
|
||||||
api_key,
|
|
||||||
model,
|
|
||||||
COALESCE(NULLIF(model_group, ''), model) AS model_group,
|
|
||||||
custom_llm_provider,
|
|
||||||
mcp_namespaced_tool_name,
|
|
||||||
endpoint,
|
|
||||||
GROUPING(date, api_key, model, COALESCE(NULLIF(model_group, ''), model),
|
|
||||||
custom_llm_provider, mcp_namespaced_tool_name,
|
|
||||||
endpoint) AS group_level,
|
|
||||||
SUM(spend)::float AS spend,
|
SUM(spend)::float AS spend,
|
||||||
{_ptu_flat_cost_select(table_name)},
|
{_ptu_flat_cost_select(table_name)},
|
||||||
SUM(prompt_tokens)::bigint AS prompt_tokens,
|
SUM(prompt_tokens)::bigint AS prompt_tokens,
|
||||||
|
|
@ -779,27 +744,113 @@ def _build_aggregated_sql_query(
|
||||||
SUM(successful_requests)::bigint AS successful_requests,
|
SUM(successful_requests)::bigint AS successful_requests,
|
||||||
SUM(failed_requests)::bigint AS failed_requests,
|
SUM(failed_requests)::bigint AS failed_requests,
|
||||||
SUM(total_response_time_ms)::bigint AS total_response_time_ms,
|
SUM(total_response_time_ms)::bigint AS total_response_time_ms,
|
||||||
SUM(timed_requests)::bigint AS timed_requests
|
SUM(timed_requests)::bigint AS timed_requests"""
|
||||||
|
|
||||||
|
|
||||||
|
_MODEL_GROUP_EXPR: Final = "COALESCE(NULLIF(model_group, ''), model)"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_aggregated_sql_query(
|
||||||
|
*,
|
||||||
|
table_name: str,
|
||||||
|
entity_id_field: str,
|
||||||
|
entity_id: str | list[str] | None, # mutable-ok: filter union shared with the paginated path
|
||||||
|
start_date: str,
|
||||||
|
end_date: str,
|
||||||
|
model: str | None,
|
||||||
|
api_key: str | list[str] | None, # mutable-ok: filter union shared with the paginated path
|
||||||
|
exclude_entity_ids: list[str] | None = None, # mutable-ok: filter union shared with the paginated path
|
||||||
|
timezone_offset_minutes: int | None = None,
|
||||||
|
include_current_utc_day: bool = False,
|
||||||
|
) -> tuple[str, list[str]]: # mutable-ok: SQL text plus its ordered $N params
|
||||||
|
"""Build the GROUPING SETS query for aggregated daily activity.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (sql_query, params_list) ready for prisma_client.db.query_raw().
|
||||||
|
"""
|
||||||
|
pg_table: Final = _PRISMA_TO_PG_TABLE.get(table_name)
|
||||||
|
if pg_table is None:
|
||||||
|
raise ValueError(f"Unknown table name: {table_name}")
|
||||||
|
|
||||||
|
adjusted_start, adjusted_end = _adjust_dates_for_timezone(
|
||||||
|
start_date, end_date, timezone_offset_minutes, include_current_utc_day
|
||||||
|
)
|
||||||
|
|
||||||
|
where_clause, where_params = _build_aggregated_where_clause(
|
||||||
|
entity_id_field=entity_id_field,
|
||||||
|
entity_id=entity_id,
|
||||||
|
adjusted_start=adjusted_start,
|
||||||
|
adjusted_end=adjusted_end,
|
||||||
|
model=model,
|
||||||
|
api_key=api_key,
|
||||||
|
exclude_entity_ids=exclude_entity_ids,
|
||||||
|
)
|
||||||
|
sentinel_param: Final = f"${len(where_params) + 1}"
|
||||||
|
metric_select: Final = _rollup_metric_select(table_name)
|
||||||
|
|
||||||
|
# TODO: drop the successful_requests/failed_requests aggregates (and the
|
||||||
|
# total_successful_requests metadata they feed) once the admin UI reads SGR
|
||||||
|
# only from LiteLLM_DailyGatewayRequests. The remaining spend, token and
|
||||||
|
# api_requests rollups are still served from here.
|
||||||
|
sql_query: Final = f"""
|
||||||
|
(SELECT
|
||||||
|
date,
|
||||||
|
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}"
|
FROM "{pg_table}"
|
||||||
WHERE {where_clause}
|
WHERE {where_clause}
|
||||||
GROUP BY GROUPING SETS (
|
GROUP BY GROUPING SETS (
|
||||||
(date),
|
(date),
|
||||||
(date, api_key),
|
|
||||||
(date, model),
|
(date, model),
|
||||||
(date, model, api_key),
|
(date, {_MODEL_GROUP_EXPR}),
|
||||||
(date, COALESCE(NULLIF(model_group, ''), model)),
|
|
||||||
(date, COALESCE(NULLIF(model_group, ''), model), api_key),
|
|
||||||
(date, custom_llm_provider),
|
(date, custom_llm_provider),
|
||||||
(date, custom_llm_provider, api_key),
|
|
||||||
(date, mcp_namespaced_tool_name),
|
(date, mcp_namespaced_tool_name),
|
||||||
(date, mcp_namespaced_tool_name, api_key),
|
|
||||||
(date, endpoint),
|
(date, endpoint),
|
||||||
(date, endpoint, api_key),
|
|
||||||
()
|
()
|
||||||
|
))
|
||||||
|
UNION ALL
|
||||||
|
(WITH top_api_keys AS (
|
||||||
|
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) DESC, api_key
|
||||||
|
LIMIT {USAGE_TOP_API_KEYS_LIMIT}
|
||||||
)
|
)
|
||||||
|
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 sql_query, sql_params
|
return sql_query, [*where_params, PTU_SENTINEL_API_KEY]
|
||||||
|
|
||||||
|
|
||||||
def _build_entity_rollup_sql_query(
|
def _build_entity_rollup_sql_query(
|
||||||
|
|
@ -844,23 +895,7 @@ def _build_entity_rollup_sql_query(
|
||||||
"{entity_id_field}" AS entity_id,
|
"{entity_id_field}" AS entity_id,
|
||||||
date,
|
date,
|
||||||
api_key,
|
api_key,
|
||||||
GROUPING(api_key) AS api_key_rolled,
|
GROUPING(api_key) AS api_key_rolled,{_rollup_metric_select(table_name)}
|
||||||
SUM(spend)::float AS spend,
|
|
||||||
{_ptu_flat_cost_select(table_name)},
|
|
||||||
SUM(prompt_tokens)::bigint AS prompt_tokens,
|
|
||||||
SUM(completion_tokens)::bigint AS completion_tokens,
|
|
||||||
SUM(cache_read_input_tokens)::bigint AS cache_read_input_tokens,
|
|
||||||
SUM(cache_creation_input_tokens)::bigint AS cache_creation_input_tokens,
|
|
||||||
SUM(compression_saved_tokens)::bigint AS compression_saved_tokens,
|
|
||||||
SUM(compression_savings_spend)::float AS compression_savings_spend,
|
|
||||||
SUM(prompt_caching_savings_spend)::float AS prompt_caching_savings_spend,
|
|
||||||
SUM(gateway_injected_caching_savings_spend)::float AS gateway_injected_caching_savings_spend,
|
|
||||||
SUM(autorouter_savings_spend)::float AS autorouter_savings_spend,
|
|
||||||
SUM(api_requests)::bigint AS api_requests,
|
|
||||||
SUM(successful_requests)::bigint AS successful_requests,
|
|
||||||
SUM(failed_requests)::bigint AS failed_requests,
|
|
||||||
SUM(total_response_time_ms)::bigint AS total_response_time_ms,
|
|
||||||
SUM(timed_requests)::bigint AS timed_requests
|
|
||||||
FROM "{pg_table}"
|
FROM "{pg_table}"
|
||||||
WHERE {where_clause}
|
WHERE {where_clause}
|
||||||
GROUP BY GROUPING SETS (
|
GROUP BY GROUPING SETS (
|
||||||
|
|
@ -962,6 +997,7 @@ async def _aggregate_spend_records(
|
||||||
# current grouping set's key), 0 when the column is part of the key.
|
# current grouping set's key), 0 when the column is part of the key.
|
||||||
_GROUP_GRAND_TOTAL: Final = 127 # 0b1111111 — all rolled up
|
_GROUP_GRAND_TOTAL: Final = 127 # 0b1111111 — all rolled up
|
||||||
_GROUP_DATE: Final = 63 # 0b0111111 — only date kept
|
_GROUP_DATE: Final = 63 # 0b0111111 — only date kept
|
||||||
|
_API_KEY_ROLLED_UP_BIT: Final = 32 # 0b0100000
|
||||||
_GROUP_DATE_API_KEY: Final = 31 # 0b0011111
|
_GROUP_DATE_API_KEY: Final = 31 # 0b0011111
|
||||||
_GROUP_DATE_MODEL: Final = 47 # 0b0101111
|
_GROUP_DATE_MODEL: Final = 47 # 0b0101111
|
||||||
_GROUP_DATE_MODEL_API_KEY: Final = 15 # 0b0001111
|
_GROUP_DATE_MODEL_API_KEY: Final = 15 # 0b0001111
|
||||||
|
|
@ -975,7 +1011,7 @@ _GROUP_DATE_ENDPOINT: Final = 62 # 0b0111110
|
||||||
_GROUP_DATE_ENDPOINT_API_KEY: Final = 30 # 0b0011110
|
_GROUP_DATE_ENDPOINT_API_KEY: Final = 30 # 0b0011110
|
||||||
|
|
||||||
|
|
||||||
def _record_to_spend_metrics(record: _GroupingSetsRow) -> SpendMetrics:
|
def _record_to_spend_metrics(record: _RollupMetricsRow) -> SpendMetrics:
|
||||||
"""Build a SpendMetrics directly from one already-aggregated rollup row.
|
"""Build a SpendMetrics directly from one already-aggregated rollup row.
|
||||||
|
|
||||||
SUM() over zero rows is SQL NULL, so rollup rows (notably the grand-total
|
SUM() over zero rows is SQL NULL, so rollup rows (notably the grand-total
|
||||||
|
|
@ -1329,10 +1365,6 @@ async def get_daily_activity_aggregated(
|
||||||
) -> SpendAnalyticsPaginatedResponse:
|
) -> SpendAnalyticsPaginatedResponse:
|
||||||
"""Aggregated variant that returns the full result set (no pagination).
|
"""Aggregated variant that returns the full result set (no pagination).
|
||||||
|
|
||||||
Uses SQL GROUP BY to aggregate rows in the database rather than fetching
|
|
||||||
all individual rows into Python. This collapses rows across entities
|
|
||||||
(users/teams/orgs), reducing ~150k rows to ~2-3k grouped rows.
|
|
||||||
|
|
||||||
include_entity_breakdown runs a small companion rollup query and folds
|
include_entity_breakdown runs a small companion rollup query and folds
|
||||||
`breakdown.entities` onto the response, as entity-scoped views like Team Usage need.
|
`breakdown.entities` onto the response, as entity-scoped views like Team Usage need.
|
||||||
|
|
||||||
|
|
@ -1351,7 +1383,7 @@ async def get_daily_activity_aggregated(
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
sql_query, sql_params = _build_aggregated_sql_query(
|
query_kwargs: Final = _AggregatedQueryKwargs(
|
||||||
table_name=table_name,
|
table_name=table_name,
|
||||||
entity_id_field=entity_id_field,
|
entity_id_field=entity_id_field,
|
||||||
entity_id=entity_id,
|
entity_id=entity_id,
|
||||||
|
|
@ -1363,36 +1395,16 @@ async def get_daily_activity_aggregated(
|
||||||
timezone_offset_minutes=timezone_offset_minutes,
|
timezone_offset_minutes=timezone_offset_minutes,
|
||||||
include_current_utc_day=include_current_utc_day,
|
include_current_utc_day=include_current_utc_day,
|
||||||
)
|
)
|
||||||
|
sql_query, sql_params = _build_aggregated_sql_query(**query_kwargs)
|
||||||
|
entity_query: Final = _build_entity_rollup_sql_query(**query_kwargs) if include_entity_breakdown else None
|
||||||
|
|
||||||
entity_query: Final = (
|
raw_rows, raw_entity_rows = await asyncio.gather(
|
||||||
_build_entity_rollup_sql_query(
|
prisma_client.db.query_raw(sql_query, *sql_params),
|
||||||
table_name=table_name,
|
_query_raw_optional(prisma_client, entity_query),
|
||||||
entity_id_field=entity_id_field,
|
|
||||||
entity_id=entity_id,
|
|
||||||
start_date=start_date,
|
|
||||||
end_date=end_date,
|
|
||||||
model=model,
|
|
||||||
api_key=api_key,
|
|
||||||
exclude_entity_ids=exclude_entity_ids,
|
|
||||||
timezone_offset_minutes=timezone_offset_minutes,
|
|
||||||
include_current_utc_day=include_current_utc_day,
|
|
||||||
)
|
|
||||||
if include_entity_breakdown
|
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Execute the GROUPING SETS query (one row per rollup level), alongside
|
records: Final = [_GroupingSetsRow(**row) for row in (raw_rows or ())]
|
||||||
# the per-entity companion rollup when the caller wants entities.
|
total_api_keys: Final = next((r.distinct_api_keys for r in records if r.distinct_api_keys is not None), 0)
|
||||||
raw_rows, raw_entity_rows = (
|
|
||||||
await asyncio.gather(
|
|
||||||
prisma_client.db.query_raw(sql_query, *sql_params),
|
|
||||||
prisma_client.db.query_raw(entity_query[0], *entity_query[1]),
|
|
||||||
)
|
|
||||||
if entity_query is not None
|
|
||||||
else (await prisma_client.db.query_raw(sql_query, *sql_params), None)
|
|
||||||
)
|
|
||||||
|
|
||||||
records: Final = [_GroupingSetsRow(**row) for row in (raw_rows or [])]
|
|
||||||
|
|
||||||
# The grouping-sets dispatcher places each row directly in its bucket
|
# The grouping-sets dispatcher places each row directly in its bucket
|
||||||
# using the row's GROUPING() bitmask. No Python-side summing needed.
|
# using the row's GROUPING() bitmask. No Python-side summing needed.
|
||||||
|
|
@ -1446,6 +1458,8 @@ async def get_daily_activity_aggregated(
|
||||||
page=1,
|
page=1,
|
||||||
total_pages=1,
|
total_pages=1,
|
||||||
has_more=False,
|
has_more=False,
|
||||||
|
api_key_limit=USAGE_TOP_API_KEYS_LIMIT,
|
||||||
|
total_api_keys=total_api_keys,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,16 @@ class DailySpendMetadata(BaseModel):
|
||||||
page: int = Field(default=1)
|
page: int = Field(default=1)
|
||||||
total_pages: int = Field(default=1)
|
total_pages: int = Field(default=1)
|
||||||
has_more: bool = Field(default=False)
|
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.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SpendAnalyticsPaginatedResponse(BaseModel):
|
class SpendAnalyticsPaginatedResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
|
import re
|
||||||
|
from collections.abc import Sequence
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Final
|
from typing import Final
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import psycopg
|
||||||
import pytest
|
import pytest
|
||||||
|
from psycopg.rows import dict_row
|
||||||
|
from pytest_postgresql import factories
|
||||||
|
|
||||||
from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR
|
from litellm.proxy.spend_tracking.ptu_feature_flag import PTU_COST_ATTRIBUTION_ENV_VAR
|
||||||
|
|
||||||
|
|
||||||
|
from litellm.constants import PTU_SENTINEL_API_KEY, USAGE_TOP_API_KEYS_LIMIT
|
||||||
from litellm.proxy.management_endpoints.common_daily_activity import (
|
from litellm.proxy.management_endpoints.common_daily_activity import (
|
||||||
_adjust_dates_for_timezone,
|
_adjust_dates_for_timezone,
|
||||||
_build_aggregated_sql_query,
|
_build_aggregated_sql_query,
|
||||||
|
|
@ -169,6 +175,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown():
|
||||||
"endpoint": "/v1/chat/completions",
|
"endpoint": "/v1/chat/completions",
|
||||||
"api_key": None,
|
"api_key": None,
|
||||||
"group_level": 62,
|
"group_level": 62,
|
||||||
|
"distinct_api_keys": None,
|
||||||
"spend": 15.0,
|
"spend": 15.0,
|
||||||
"prompt_tokens": 150,
|
"prompt_tokens": 150,
|
||||||
"completion_tokens": 75,
|
"completion_tokens": 75,
|
||||||
|
|
@ -181,31 +188,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown():
|
||||||
"endpoint": "/v1/embeddings",
|
"endpoint": "/v1/embeddings",
|
||||||
"api_key": None,
|
"api_key": None,
|
||||||
"group_level": 62,
|
"group_level": 62,
|
||||||
"spend": 3.0,
|
"distinct_api_keys": None,
|
||||||
"prompt_tokens": 30,
|
|
||||||
"completion_tokens": 0,
|
|
||||||
"api_requests": 1,
|
|
||||||
"successful_requests": 1,
|
|
||||||
},
|
|
||||||
# (date, endpoint, api_key) — populates the per-key sub-bucket
|
|
||||||
{
|
|
||||||
**base,
|
|
||||||
"date": "2024-01-01",
|
|
||||||
"endpoint": "/v1/chat/completions",
|
|
||||||
"api_key": "key-1",
|
|
||||||
"group_level": 30,
|
|
||||||
"spend": 15.0,
|
|
||||||
"prompt_tokens": 150,
|
|
||||||
"completion_tokens": 75,
|
|
||||||
"api_requests": 2,
|
|
||||||
"successful_requests": 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
**base,
|
|
||||||
"date": "2024-01-01",
|
|
||||||
"endpoint": "/v1/embeddings",
|
|
||||||
"api_key": "key-2",
|
|
||||||
"group_level": 30,
|
|
||||||
"spend": 3.0,
|
"spend": 3.0,
|
||||||
"prompt_tokens": 30,
|
"prompt_tokens": 30,
|
||||||
"completion_tokens": 0,
|
"completion_tokens": 0,
|
||||||
|
|
@ -219,6 +202,7 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown():
|
||||||
"endpoint": None,
|
"endpoint": None,
|
||||||
"api_key": None,
|
"api_key": None,
|
||||||
"group_level": 63,
|
"group_level": 63,
|
||||||
|
"distinct_api_keys": None,
|
||||||
"spend": 18.0,
|
"spend": 18.0,
|
||||||
"prompt_tokens": 180,
|
"prompt_tokens": 180,
|
||||||
"completion_tokens": 75,
|
"completion_tokens": 75,
|
||||||
|
|
@ -232,12 +216,40 @@ async def test_get_daily_activity_aggregated_with_endpoint_breakdown():
|
||||||
"endpoint": None,
|
"endpoint": None,
|
||||||
"api_key": None,
|
"api_key": None,
|
||||||
"group_level": 127,
|
"group_level": 127,
|
||||||
|
"distinct_api_keys": None,
|
||||||
"spend": 18.0,
|
"spend": 18.0,
|
||||||
"prompt_tokens": 180,
|
"prompt_tokens": 180,
|
||||||
"completion_tokens": 75,
|
"completion_tokens": 75,
|
||||||
"api_requests": 3,
|
"api_requests": 3,
|
||||||
"successful_requests": 3,
|
"successful_requests": 3,
|
||||||
},
|
},
|
||||||
|
# (date, endpoint, api_key) — populates the per-key sub-bucket
|
||||||
|
{
|
||||||
|
**base,
|
||||||
|
"date": "2024-01-01",
|
||||||
|
"endpoint": "/v1/chat/completions",
|
||||||
|
"api_key": "key-1",
|
||||||
|
"group_level": 30,
|
||||||
|
"distinct_api_keys": 2,
|
||||||
|
"spend": 15.0,
|
||||||
|
"prompt_tokens": 150,
|
||||||
|
"completion_tokens": 75,
|
||||||
|
"api_requests": 2,
|
||||||
|
"successful_requests": 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
**base,
|
||||||
|
"date": "2024-01-01",
|
||||||
|
"endpoint": "/v1/embeddings",
|
||||||
|
"api_key": "key-2",
|
||||||
|
"group_level": 30,
|
||||||
|
"distinct_api_keys": 2,
|
||||||
|
"spend": 3.0,
|
||||||
|
"prompt_tokens": 30,
|
||||||
|
"completion_tokens": 0,
|
||||||
|
"api_requests": 1,
|
||||||
|
"successful_requests": 1,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
mock_prisma.db.query_raw = AsyncMock(return_value=mock_rows)
|
mock_prisma.db.query_raw = AsyncMock(return_value=mock_rows)
|
||||||
|
|
@ -474,9 +486,7 @@ async def test_get_api_key_metadata_recovers_double_hashed_key_via_reverse_hash(
|
||||||
return_value=[SimpleNamespace(user_id="alice", user_email="alice@example.com")]
|
return_value=[SimpleNamespace(user_id="alice", user_email="alice@example.com")]
|
||||||
)
|
)
|
||||||
mock_prisma.db.query_raw = AsyncMock(
|
mock_prisma.db.query_raw = AsyncMock(
|
||||||
return_value=[
|
return_value=[{"digest": double_hashed, "key_alias": "batch-worker", "team_id": "team-1", "user_id": "alice"}]
|
||||||
{"digest": double_hashed, "key_alias": "batch-worker", "team_id": "team-1", "user_id": "alice"}
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await get_api_key_metadata(
|
result = await get_api_key_metadata(
|
||||||
|
|
@ -835,6 +845,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
|
||||||
"endpoint": "/v1/chat/completions",
|
"endpoint": "/v1/chat/completions",
|
||||||
"api_key": None,
|
"api_key": None,
|
||||||
"group_level": 62,
|
"group_level": 62,
|
||||||
|
"distinct_api_keys": None,
|
||||||
"spend": 10.0,
|
"spend": 10.0,
|
||||||
"prompt_tokens": 100,
|
"prompt_tokens": 100,
|
||||||
"completion_tokens": 50,
|
"completion_tokens": 50,
|
||||||
|
|
@ -847,6 +858,7 @@ async def test_aggregated_activity_preserves_metadata_for_deleted_keys():
|
||||||
"endpoint": "/v1/chat/completions",
|
"endpoint": "/v1/chat/completions",
|
||||||
"api_key": "deleted-key-hash",
|
"api_key": "deleted-key-hash",
|
||||||
"group_level": 30,
|
"group_level": 30,
|
||||||
|
"distinct_api_keys": 1,
|
||||||
"spend": 10.0,
|
"spend": 10.0,
|
||||||
"prompt_tokens": 100,
|
"prompt_tokens": 100,
|
||||||
"completion_tokens": 50,
|
"completion_tokens": 50,
|
||||||
|
|
@ -1230,42 +1242,11 @@ class TestBuildAggregatedSqlQuery:
|
||||||
"user-1",
|
"user-1",
|
||||||
"bedrock/global.anthropic.claude-opus-4-8",
|
"bedrock/global.anthropic.claude-opus-4-8",
|
||||||
"sk-test",
|
"sk-test",
|
||||||
|
PTU_SENTINEL_API_KEY,
|
||||||
]
|
]
|
||||||
assert "model = $4" in sql
|
assert "model = $4" in sql
|
||||||
assert "api_key = $5" in sql
|
assert "api_key = $5" in sql
|
||||||
|
|
||||||
def test_model_group_rollups_fall_back_to_model_name(self):
|
|
||||||
"""Aggregated model_groups rollups must fall back to model for group-less rows.
|
|
||||||
|
|
||||||
The (date, model_group) grouping level cannot recover the model column
|
|
||||||
after the fact (it is rolled up), so the fallback has to happen in SQL;
|
|
||||||
without it, group-less rows silently vanish from the model_groups
|
|
||||||
breakdown that the usage UI now renders by default. Group-less rows are
|
|
||||||
stored as empty strings, not NULL (spend_tracking_utils defaults
|
|
||||||
model_group to ""), so a plain COALESCE is not enough: the fallback must
|
|
||||||
be NULLIF-wrapped to catch both
|
|
||||||
"""
|
|
||||||
sql, _ = _build_aggregated_sql_query(
|
|
||||||
table_name="litellm_dailyuserspend",
|
|
||||||
entity_id_field="user_id",
|
|
||||||
entity_id=None,
|
|
||||||
start_date="2026-07-01",
|
|
||||||
end_date="2026-07-01",
|
|
||||||
model=None,
|
|
||||||
api_key=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
normalized = " ".join(sql.split())
|
|
||||||
fallback = "COALESCE(NULLIF(model_group, ''), model)"
|
|
||||||
assert f"{fallback} AS model_group" in normalized
|
|
||||||
assert (
|
|
||||||
f"GROUPING(date, api_key, model, {fallback}, "
|
|
||||||
"custom_llm_provider, mcp_namespaced_tool_name, endpoint) AS group_level" in normalized
|
|
||||||
)
|
|
||||||
assert f"(date, {fallback}), (date, {fallback}, api_key)," in normalized
|
|
||||||
assert "(date, model_group)" not in normalized
|
|
||||||
assert "COALESCE(model_group, model)" not in normalized
|
|
||||||
|
|
||||||
|
|
||||||
class TestAggregatedEmptyEntityFilter:
|
class TestAggregatedEmptyEntityFilter:
|
||||||
_BUILDERS: Final = (_build_aggregated_sql_query, _build_entity_rollup_sql_query)
|
_BUILDERS: Final = (_build_aggregated_sql_query, _build_entity_rollup_sql_query)
|
||||||
|
|
@ -1285,7 +1266,8 @@ class TestAggregatedEmptyEntityFilter:
|
||||||
normalized = " ".join(sql.split())
|
normalized = " ".join(sql.split())
|
||||||
assert "IN ()" not in normalized
|
assert "IN ()" not in normalized
|
||||||
assert '"team_id" IN' not in normalized
|
assert '"team_id" IN' not in normalized
|
||||||
assert params == ["2026-08-01", "2026-08-19"]
|
sentinel_params = [PTU_SENTINEL_API_KEY] if build is _build_aggregated_sql_query else []
|
||||||
|
assert params == ["2026-08-01", "2026-08-19", *sentinel_params]
|
||||||
|
|
||||||
@pytest.mark.parametrize("build", _BUILDERS)
|
@pytest.mark.parametrize("build", _BUILDERS)
|
||||||
def test_empty_entity_list_matches_nothing_rather_than_everything(self, build):
|
def test_empty_entity_list_matches_nothing_rather_than_everything(self, build):
|
||||||
|
|
@ -1316,7 +1298,8 @@ class TestAggregatedEmptyEntityFilter:
|
||||||
normalized = " ".join(sql.split())
|
normalized = " ".join(sql.split())
|
||||||
assert '"team_id" IN ($3, $4)' in normalized
|
assert '"team_id" IN ($3, $4)' in normalized
|
||||||
assert "FALSE" not in normalized
|
assert "FALSE" not in normalized
|
||||||
assert params == ["2026-08-01", "2026-08-19", "team-alpha", "team-beta"]
|
sentinel_params = [PTU_SENTINEL_API_KEY] if build is _build_aggregated_sql_query else []
|
||||||
|
assert params == ["2026-08-01", "2026-08-19", "team-alpha", "team-beta", *sentinel_params]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -1341,6 +1324,7 @@ async def test_get_daily_activity_aggregated_empty_result_set():
|
||||||
"mcp_namespaced_tool_name": None,
|
"mcp_namespaced_tool_name": None,
|
||||||
"endpoint": None,
|
"endpoint": None,
|
||||||
"group_level": 127,
|
"group_level": 127,
|
||||||
|
"distinct_api_keys": None,
|
||||||
"spend": None,
|
"spend": None,
|
||||||
"prompt_tokens": None,
|
"prompt_tokens": None,
|
||||||
"completion_tokens": None,
|
"completion_tokens": None,
|
||||||
|
|
@ -1385,6 +1369,305 @@ async def test_get_daily_activity_aggregated_empty_result_set():
|
||||||
assert result.metadata.total_compression_saved_tokens == 0
|
assert result.metadata.total_compression_saved_tokens == 0
|
||||||
|
|
||||||
|
|
||||||
|
_aggregated_postgresql_proc: Final = factories.postgresql_proc()
|
||||||
|
_aggregated_postgresql: Final = factories.postgresql("_aggregated_postgresql_proc")
|
||||||
|
|
||||||
|
_DAILY_USER_SPEND_DDL: Final = """
|
||||||
|
CREATE TABLE "LiteLLM_DailyUserSpend" (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
api_key TEXT NOT NULL,
|
||||||
|
model TEXT,
|
||||||
|
model_group TEXT,
|
||||||
|
custom_llm_provider TEXT,
|
||||||
|
mcp_namespaced_tool_name TEXT,
|
||||||
|
endpoint TEXT,
|
||||||
|
prompt_tokens BIGINT DEFAULT 0,
|
||||||
|
completion_tokens BIGINT DEFAULT 0,
|
||||||
|
cache_read_input_tokens BIGINT DEFAULT 0,
|
||||||
|
cache_creation_input_tokens BIGINT DEFAULT 0,
|
||||||
|
compression_saved_tokens BIGINT DEFAULT 0,
|
||||||
|
compression_savings_spend DOUBLE PRECISION DEFAULT 0,
|
||||||
|
prompt_caching_savings_spend DOUBLE PRECISION DEFAULT 0,
|
||||||
|
gateway_injected_caching_savings_spend DOUBLE PRECISION DEFAULT 0,
|
||||||
|
autorouter_savings_spend DOUBLE PRECISION DEFAULT 0,
|
||||||
|
spend DOUBLE PRECISION DEFAULT 0,
|
||||||
|
api_requests BIGINT DEFAULT 0,
|
||||||
|
successful_requests BIGINT DEFAULT 0,
|
||||||
|
failed_requests BIGINT DEFAULT 0,
|
||||||
|
total_response_time_ms BIGINT DEFAULT 0,
|
||||||
|
timed_requests BIGINT DEFAULT 0
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_daily_user_spend(conn: psycopg.Connection, rows: Sequence[tuple[object, ...]]) -> None:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(_DAILY_USER_SPEND_DDL)
|
||||||
|
cur.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO "LiteLLM_DailyUserSpend"
|
||||||
|
(id, user_id, date, api_key, model, model_group, custom_llm_provider,
|
||||||
|
endpoint, prompt_tokens, spend, api_requests, successful_requests)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _psycopg_query_raw(conn: psycopg.Connection, row_counts: list[int]):
|
||||||
|
"""Run the proxy's $N-parameterized SQL through psycopg, recording each result size."""
|
||||||
|
|
||||||
|
async def query_raw(sql: str, *params: str) -> list[dict[str, object]]:
|
||||||
|
converted: Final = re.sub(r"\$(\d+)", r"%(p\1)s", sql)
|
||||||
|
with conn.cursor(row_factory=dict_row) as cur:
|
||||||
|
cur.execute(
|
||||||
|
converted, # pyright: ignore[reportArgumentType] # psycopg stubs want a literal-typed query
|
||||||
|
{f"p{i}": v for i, v in enumerate(params, start=1)},
|
||||||
|
)
|
||||||
|
rows: Final = cur.fetchall()
|
||||||
|
row_counts.append(len(rows))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
return query_raw
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_daily_activity_aggregated_bounds_api_key_rollups(
|
||||||
|
_aggregated_postgresql: psycopg.Connection,
|
||||||
|
):
|
||||||
|
"""Run the GROUPING SETS statement against real Postgres with more keys than the cap.
|
||||||
|
|
||||||
|
key-004 and key-005 tie on spend exactly at the USAGE_TOP_API_KEYS_LIMIT
|
||||||
|
cutoff; the api_key tiebreaker must keep key-004 and drop key-005. The PTU
|
||||||
|
sentinel outspends every key but must not take a slot. Excluded keys and the
|
||||||
|
sentinel still count toward the totals and the model rollup, which come from
|
||||||
|
the key-free arm.
|
||||||
|
"""
|
||||||
|
n_keys: Final = USAGE_TOP_API_KEYS_LIMIT + 5
|
||||||
|
key_rows: Final = [
|
||||||
|
(
|
||||||
|
f"row-{i:03d}",
|
||||||
|
f"user-{i:03d}",
|
||||||
|
"2026-06-01",
|
||||||
|
f"key-{i:03d}",
|
||||||
|
"gpt-5",
|
||||||
|
"",
|
||||||
|
"openai",
|
||||||
|
"/v1/chat/completions",
|
||||||
|
10,
|
||||||
|
6.0 if i == 4 else float(i + 1),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
for i in range(n_keys)
|
||||||
|
]
|
||||||
|
sentinel_row: Final = (
|
||||||
|
"row-ptu",
|
||||||
|
None,
|
||||||
|
"2026-06-01",
|
||||||
|
PTU_SENTINEL_API_KEY,
|
||||||
|
"gpt-5",
|
||||||
|
"",
|
||||||
|
"azure",
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
1000.0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
_seed_daily_user_spend(_aggregated_postgresql, [*key_rows, sentinel_row])
|
||||||
|
key_spend: Final = sum(6.0 if i == 4 else float(i + 1) for i in range(n_keys))
|
||||||
|
|
||||||
|
row_counts: Final[list[int]] = [] # mutable-ok: out-param for the query_raw shim
|
||||||
|
mock_prisma = MagicMock()
|
||||||
|
mock_prisma.db = MagicMock()
|
||||||
|
mock_prisma.db.query_raw = _psycopg_query_raw(_aggregated_postgresql, row_counts)
|
||||||
|
mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
|
||||||
|
mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
result = await get_daily_activity_aggregated(
|
||||||
|
prisma_client=mock_prisma,
|
||||||
|
table_name="litellm_dailyuserspend",
|
||||||
|
entity_id_field="user_id",
|
||||||
|
entity_id=None,
|
||||||
|
entity_metadata_field=None,
|
||||||
|
start_date="2026-06-01",
|
||||||
|
end_date="2026-06-01",
|
||||||
|
model=None,
|
||||||
|
api_key=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Key-free arm: (), (date), (date, model), (date, model_group), two providers,
|
||||||
|
# one mcp NULL bucket, endpoint plus its NULL bucket = 9 rows regardless of key count.
|
||||||
|
# Per-key arm: six per-key grouping sets, each capped at the limit.
|
||||||
|
assert row_counts == [9 + 6 * USAGE_TOP_API_KEYS_LIMIT]
|
||||||
|
|
||||||
|
assert result.metadata.total_spend == pytest.approx(key_spend + 1000.0)
|
||||||
|
assert result.metadata.total_api_requests == n_keys
|
||||||
|
assert result.metadata.api_key_limit == USAGE_TOP_API_KEYS_LIMIT
|
||||||
|
assert result.metadata.total_api_keys == n_keys
|
||||||
|
|
||||||
|
expected_top: Final = {f"key-{i:03d}" for i in range(6, n_keys)} | {"key-004"}
|
||||||
|
day: Final = result.results[0]
|
||||||
|
assert day.metrics.spend == pytest.approx(key_spend + 1000.0)
|
||||||
|
assert set(day.breakdown.api_keys) == expected_top
|
||||||
|
assert day.breakdown.api_keys["key-004"].metrics.spend == 6.0
|
||||||
|
assert "key-005" not in day.breakdown.api_keys
|
||||||
|
assert PTU_SENTINEL_API_KEY not in day.breakdown.api_keys
|
||||||
|
|
||||||
|
assert day.breakdown.models["gpt-5"].metrics.spend == pytest.approx(key_spend + 1000.0)
|
||||||
|
assert set(day.breakdown.models["gpt-5"].api_key_breakdown) == expected_top
|
||||||
|
assert day.breakdown.providers["openai"].metrics.spend == pytest.approx(key_spend)
|
||||||
|
assert set(day.breakdown.providers["openai"].api_key_breakdown) == expected_top
|
||||||
|
assert day.breakdown.endpoints["/v1/chat/completions"].metrics.api_requests == n_keys
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_daily_activity_aggregated_explicit_api_key_filter_scopes_both_arms(
|
||||||
|
_aggregated_postgresql: psycopg.Connection,
|
||||||
|
):
|
||||||
|
"""An explicit api_key filter must scope the key-free totals and the per-key
|
||||||
|
rollups to that key alone, so the two arms never disagree."""
|
||||||
|
rows: Final = [
|
||||||
|
(
|
||||||
|
f"row-{i}",
|
||||||
|
f"user-{i}",
|
||||||
|
"2026-06-01",
|
||||||
|
f"key-{i}",
|
||||||
|
"gpt-5",
|
||||||
|
"",
|
||||||
|
"openai",
|
||||||
|
"/v1/chat/completions",
|
||||||
|
10,
|
||||||
|
float(i + 1),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
_seed_daily_user_spend(_aggregated_postgresql, rows)
|
||||||
|
|
||||||
|
row_counts: Final[list[int]] = [] # mutable-ok: out-param for the query_raw shim
|
||||||
|
mock_prisma = MagicMock()
|
||||||
|
mock_prisma.db = MagicMock()
|
||||||
|
mock_prisma.db.query_raw = _psycopg_query_raw(_aggregated_postgresql, row_counts)
|
||||||
|
mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
|
||||||
|
mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
result = await get_daily_activity_aggregated(
|
||||||
|
prisma_client=mock_prisma,
|
||||||
|
table_name="litellm_dailyuserspend",
|
||||||
|
entity_id_field="user_id",
|
||||||
|
entity_id=None,
|
||||||
|
entity_metadata_field=None,
|
||||||
|
start_date="2026-06-01",
|
||||||
|
end_date="2026-06-01",
|
||||||
|
model=None,
|
||||||
|
api_key="key-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.metadata.total_spend == 2.0
|
||||||
|
assert result.metadata.total_api_keys == 1
|
||||||
|
day: Final = result.results[0]
|
||||||
|
assert set(day.breakdown.api_keys) == {"key-1"}
|
||||||
|
assert day.breakdown.api_keys["key-1"].metrics.spend == 2.0
|
||||||
|
assert day.breakdown.models["gpt-5"].metrics.spend == 2.0
|
||||||
|
assert set(day.breakdown.models["gpt-5"].api_key_breakdown) == {"key-1"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_daily_activity_aggregated_reports_exact_limit_key_count_as_complete(
|
||||||
|
_aggregated_postgresql: psycopg.Connection,
|
||||||
|
):
|
||||||
|
"""With exactly USAGE_TOP_API_KEYS_LIMIT keys nothing is dropped, and the
|
||||||
|
response must say so: total_api_keys equals the limit rather than exceeding it."""
|
||||||
|
rows: Final = [
|
||||||
|
(
|
||||||
|
f"row-{i:03d}",
|
||||||
|
f"user-{i:03d}",
|
||||||
|
"2026-06-01",
|
||||||
|
f"key-{i:03d}",
|
||||||
|
"gpt-5",
|
||||||
|
"",
|
||||||
|
"openai",
|
||||||
|
"/v1/chat/completions",
|
||||||
|
10,
|
||||||
|
float(i + 1),
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
for i in range(USAGE_TOP_API_KEYS_LIMIT)
|
||||||
|
]
|
||||||
|
_seed_daily_user_spend(_aggregated_postgresql, rows)
|
||||||
|
|
||||||
|
row_counts: Final[list[int]] = [] # mutable-ok: out-param for the query_raw shim
|
||||||
|
mock_prisma = MagicMock()
|
||||||
|
mock_prisma.db = MagicMock()
|
||||||
|
mock_prisma.db.query_raw = _psycopg_query_raw(_aggregated_postgresql, row_counts)
|
||||||
|
mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
|
||||||
|
mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
result = await get_daily_activity_aggregated(
|
||||||
|
prisma_client=mock_prisma,
|
||||||
|
table_name="litellm_dailyuserspend",
|
||||||
|
entity_id_field="user_id",
|
||||||
|
entity_id=None,
|
||||||
|
entity_metadata_field=None,
|
||||||
|
start_date="2026-06-01",
|
||||||
|
end_date="2026-06-01",
|
||||||
|
model=None,
|
||||||
|
api_key=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.metadata.total_api_keys == USAGE_TOP_API_KEYS_LIMIT
|
||||||
|
assert result.metadata.api_key_limit == USAGE_TOP_API_KEYS_LIMIT
|
||||||
|
assert len(result.results[0].breakdown.api_keys) == USAGE_TOP_API_KEYS_LIMIT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_daily_activity_aggregated_model_group_rollups_fall_back_to_model_name(
|
||||||
|
_aggregated_postgresql: psycopg.Connection,
|
||||||
|
):
|
||||||
|
"""Rows stored with an empty or NULL model_group must land in the model_groups
|
||||||
|
breakdown under their model name instead of vanishing from the usage UI."""
|
||||||
|
rows: Final = [
|
||||||
|
("row-0", "user-0", "2026-06-01", "key-0", "gpt-5", "gpt-5-eu", "openai", "/v1/chat/completions", 10, 7.0, 1, 1),
|
||||||
|
("row-1", "user-1", "2026-06-01", "key-1", "gpt-5", "", "openai", "/v1/chat/completions", 10, 3.0, 1, 1),
|
||||||
|
("row-2", "user-2", "2026-06-01", "key-2", "claude-x", None, "anthropic", "/v1/messages", 10, 2.0, 1, 1),
|
||||||
|
]
|
||||||
|
_seed_daily_user_spend(_aggregated_postgresql, rows)
|
||||||
|
|
||||||
|
mock_prisma = MagicMock()
|
||||||
|
mock_prisma.db = MagicMock()
|
||||||
|
mock_prisma.db.query_raw = _psycopg_query_raw(_aggregated_postgresql, [])
|
||||||
|
mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
|
||||||
|
mock_prisma.db.litellm_deletedverificationtoken.find_many = AsyncMock(return_value=[])
|
||||||
|
|
||||||
|
result = await get_daily_activity_aggregated(
|
||||||
|
prisma_client=mock_prisma,
|
||||||
|
table_name="litellm_dailyuserspend",
|
||||||
|
entity_id_field="user_id",
|
||||||
|
entity_id=None,
|
||||||
|
entity_metadata_field=None,
|
||||||
|
start_date="2026-06-01",
|
||||||
|
end_date="2026-06-01",
|
||||||
|
model=None,
|
||||||
|
api_key=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
breakdown: Final = result.results[0].breakdown
|
||||||
|
assert set(breakdown.model_groups) == {"gpt-5-eu", "gpt-5", "claude-x"}
|
||||||
|
assert breakdown.model_groups["gpt-5-eu"].metrics.spend == 7.0
|
||||||
|
assert breakdown.model_groups["gpt-5"].metrics.spend == 3.0
|
||||||
|
assert breakdown.model_groups["claude-x"].metrics.spend == 2.0
|
||||||
|
assert set(breakdown.model_groups["gpt-5"].api_key_breakdown) == {"key-1"}
|
||||||
|
assert set(breakdown.models) == {"gpt-5", "claude-x"}
|
||||||
|
assert breakdown.models["gpt-5"].metrics.spend == 10.0
|
||||||
|
|
||||||
|
|
||||||
def _no_spend_record():
|
def _no_spend_record():
|
||||||
"""A rollup row for a key with no spend, where SUM() returns NULL (None)."""
|
"""A rollup row for a key with no spend, where SUM() returns NULL (None)."""
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
|
|
@ -2170,7 +2453,7 @@ def test_entity_rollup_sql_query_and_api_key_list_filter():
|
||||||
api_key=[],
|
api_key=[],
|
||||||
)
|
)
|
||||||
assert "FALSE" in empty_sql
|
assert "FALSE" in empty_sql
|
||||||
assert empty_params == ["2024-01-01", "2024-01-31"]
|
assert empty_params == ["2024-01-01", "2024-01-31", PTU_SENTINEL_API_KEY]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|
@ -2204,10 +2487,10 @@ async def test_get_daily_activity_aggregated_with_entity_breakdown():
|
||||||
"successful_requests": 0,
|
"successful_requests": 0,
|
||||||
}
|
}
|
||||||
main_rows = [
|
main_rows = [
|
||||||
{**base, "date": None, "group_level": 127, "spend": 18.0},
|
{**base, "date": None, "group_level": 127, "distinct_api_keys": None, "spend": 18.0},
|
||||||
{**base, "date": "2024-01-01", "group_level": 63, "spend": 18.0},
|
{**base, "date": "2024-01-01", "group_level": 63, "distinct_api_keys": None, "spend": 18.0},
|
||||||
{**base, "date": "2024-01-01", "model": "gpt-4o", "group_level": 47, "spend": 18.0},
|
{**base, "date": "2024-01-01", "model": "gpt-4o", "group_level": 47, "distinct_api_keys": None, "spend": 18.0},
|
||||||
{**base, "date": "2024-01-01", "api_key": "key-1", "group_level": 31, "spend": 12.0},
|
{**base, "date": "2024-01-01", "api_key": "key-1", "group_level": 31, "distinct_api_keys": 1, "spend": 12.0},
|
||||||
]
|
]
|
||||||
entity_base = {
|
entity_base = {
|
||||||
key: value
|
key: value
|
||||||
|
|
|
||||||
|
|
@ -178,4 +178,28 @@ describe("CacheLeakageCard", () => {
|
||||||
screen.queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."),
|
screen.queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says which keys are missing from the key ranking when the proxy capped the per-key lists", () => {
|
||||||
|
const day = dayWithKeys("2026-07-12", {
|
||||||
|
"hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
|
||||||
|
});
|
||||||
|
renderWith([day], { apiKeyTruncation: { limit: 100, total: 3000 } });
|
||||||
|
|
||||||
|
expect(screen.getByRole("note")).toHaveTextContent(
|
||||||
|
"Only the 100 highest-spend keys of 3,000 are loaded, so a lower-spend key that leaks more is not listed here.",
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "By model" }));
|
||||||
|
|
||||||
|
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the key ranking note off when every key was loaded", () => {
|
||||||
|
const day = dayWithKeys("2026-07-12", {
|
||||||
|
"hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
|
||||||
|
});
|
||||||
|
renderWith([day]);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,7 @@ const SortableHead = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
|
const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
|
||||||
const { dateValue, onDateChange, results, loading, isFetchingMore } = activity;
|
const { dateValue, onDateChange, results, loading, isFetchingMore, apiKeyTruncation } = activity;
|
||||||
const [dimension, setDimension] = useState<CacheLeakageDimension>("key");
|
const [dimension, setDimension] = useState<CacheLeakageDimension>("key");
|
||||||
const [sort, setSort] = useState<SortState>({ column: "potentialSavings", dir: "desc" });
|
const [sort, setSort] = useState<SortState>({ column: "potentialSavings", dir: "desc" });
|
||||||
const leakage = useMemo(() => computeCacheLeakage(results, dimension), [results, dimension]);
|
const leakage = useMemo(() => computeCacheLeakage(results, dimension), [results, dimension]);
|
||||||
|
|
@ -123,6 +123,13 @@ const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{dimension === "key" && apiKeyTruncation !== undefined && (
|
||||||
|
<p className="mb-2 text-sm text-muted-foreground" role="note">
|
||||||
|
Only the {apiKeyTruncation.limit.toLocaleString()} highest-spend keys of{" "}
|
||||||
|
{apiKeyTruncation.total.toLocaleString()} are loaded, so a lower-spend key that leaks more is not listed
|
||||||
|
here. Raise USAGE_TOP_API_KEYS_LIMIT on the proxy to load more keys.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{rows.length > 0 && isFetchingMore && (
|
{rows.length > 0 && isFetchingMore && (
|
||||||
<p className="mb-2 text-sm text-muted-foreground">
|
<p className="mb-2 text-sm text-muted-foreground">
|
||||||
Data is still loading; rows and totals will update as the rest of the range arrives.
|
Data is still loading; rows and totals will update as the rest of the range arrives.
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ import { describe, expect, it, vi } from "vitest";
|
||||||
const mockUsePaginatedDailyActivity = vi.fn();
|
const mockUsePaginatedDailyActivity = vi.fn();
|
||||||
|
|
||||||
const mockCancel = vi.fn();
|
const mockCancel = vi.fn();
|
||||||
|
let mockMetadata: Record<string, number> = {};
|
||||||
|
|
||||||
vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", () => ({
|
vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", () => ({
|
||||||
usePaginatedDailyActivity: (args: unknown) => {
|
usePaginatedDailyActivity: (args: unknown) => {
|
||||||
mockUsePaginatedDailyActivity(args);
|
mockUsePaginatedDailyActivity(args);
|
||||||
return {
|
return {
|
||||||
data: { results: [] },
|
data: { results: [], metadata: mockMetadata },
|
||||||
loading: false,
|
loading: false,
|
||||||
isFetchingMore: false,
|
isFetchingMore: false,
|
||||||
progress: { currentPage: 4, totalPages: 9 },
|
progress: { currentPage: 4, totalPages: 9 },
|
||||||
|
|
@ -80,4 +81,18 @@ describe("useDailyActivityRange", () => {
|
||||||
|
|
||||||
expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: false }));
|
expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: false }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports how many keys the proxy left out of the per-key lists", () => {
|
||||||
|
mockMetadata = { api_key_limit: 100, total_api_keys: 3000 };
|
||||||
|
const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));
|
||||||
|
|
||||||
|
expect(result.current.apiKeyTruncation).toEqual({ limit: 100, total: 3000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports no key truncation when every key fit under the proxy limit", () => {
|
||||||
|
mockMetadata = { api_key_limit: 100, total_api_keys: 100 };
|
||||||
|
const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));
|
||||||
|
|
||||||
|
expect(result.current.apiKeyTruncation).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { userDailyActivityAggregatedCall, userDailyActivityCall } from "@/components/networking";
|
import { userDailyActivityAggregatedCall, userDailyActivityCall } from "@/components/networking";
|
||||||
|
import { ApiKeyTruncation, getApiKeyTruncation } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||||
import { DailyData } from "@/components/UsagePage/types";
|
import { DailyData } from "@/components/UsagePage/types";
|
||||||
import { spendScopeUserId } from "@/utils/roles";
|
import { spendScopeUserId } from "@/utils/roles";
|
||||||
import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity";
|
import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity";
|
||||||
|
|
@ -22,6 +23,7 @@ export interface DailyActivityRange {
|
||||||
cancelled: boolean;
|
cancelled: boolean;
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
|
apiKeyTruncation?: ApiKeyTruncation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -78,6 +80,7 @@ export const useScopedDailyActivityRange = (
|
||||||
cancelled,
|
cancelled,
|
||||||
failed,
|
failed,
|
||||||
cancel,
|
cancel,
|
||||||
|
apiKeyTruncation: getApiKeyTruncation(data.metadata?.api_key_limit, data.metadata?.total_api_keys),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -569,6 +569,23 @@ describe("EntityUsage", () => {
|
||||||
expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument();
|
expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tells the team view how many keys the proxy left out of the per-key lists", async () => {
|
||||||
|
mockTeamDailyActivityAggregatedCall.mockResolvedValue({
|
||||||
|
...mockSpendData,
|
||||||
|
metadata: { ...mockSpendData.metadata, api_key_limit: 100, total_api_keys: 3000 },
|
||||||
|
});
|
||||||
|
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(screen.getByText("Key Activity"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByRole("note")).toHaveTextContent("Only the 100 highest-spend keys of 3,000 are loaded");
|
||||||
|
});
|
||||||
|
|
||||||
// An inactive tab panel is marked aria-selected="false" by one tab library and hidden by the
|
// An inactive tab panel is marked aria-selected="false" by one tab library and hidden by the
|
||||||
// other, so treat either as "not on screen" and the assertion holds whichever one is rendering.
|
// other, so treat either as "not on screen" and the assertion holds whichever one is rendering.
|
||||||
const isShowing = (element: HTMLElement): boolean => {
|
const isShowing = (element: HTMLElement): boolean => {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import TeamMultiSelect from "@/components/common_components/team_multi_select";
|
||||||
import UserDropdown from "@/components/common_components/UserDropdown";
|
import UserDropdown from "@/components/common_components/UserDropdown";
|
||||||
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
|
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
|
||||||
import { UsageExportHeader } from "@/components/EntityUsageExport";
|
import { UsageExportHeader } from "@/components/EntityUsageExport";
|
||||||
import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
|
import { getApiKeyTruncation, getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||||
import type { EntityType } from "@/components/EntityUsageExport/types";
|
import type { EntityType } from "@/components/EntityUsageExport/types";
|
||||||
import {
|
import {
|
||||||
agentDailyActivityCall,
|
agentDailyActivityCall,
|
||||||
|
|
@ -71,6 +71,8 @@ interface EntitySpendData {
|
||||||
total_successful_requests: number;
|
total_successful_requests: number;
|
||||||
total_failed_requests: number;
|
total_failed_requests: number;
|
||||||
total_tokens: number;
|
total_tokens: number;
|
||||||
|
api_key_limit?: number | null;
|
||||||
|
total_api_keys?: number | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,6 +162,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
const spendData = spendDataRaw as unknown as EntitySpendData;
|
const spendData = spendDataRaw as unknown as EntitySpendData;
|
||||||
|
const apiKeyTruncation = getApiKeyTruncation(spendData.metadata?.api_key_limit, spendData.metadata?.total_api_keys);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: agentSpendDataRaw,
|
data: agentSpendDataRaw,
|
||||||
|
|
@ -659,12 +662,18 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
||||||
{
|
{
|
||||||
key: "keys",
|
key: "keys",
|
||||||
label: "Key Activity",
|
label: "Key Activity",
|
||||||
content: <KeyActivityPanel keyMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />,
|
content: (
|
||||||
|
<KeyActivityPanel
|
||||||
|
keyMetrics={keyMetrics}
|
||||||
|
hidePromptCachingMetrics={entityType === "agent"}
|
||||||
|
apiKeyTruncation={apiKeyTruncation}
|
||||||
|
/>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
|
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const spendFetchState = { coversRange, cancelled, failed };
|
const spendFetchState = { coversRange, cancelled, failed, apiKeyTruncation };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: "100%" }} className="relative">
|
<div style={{ width: "100%" }} className="relative">
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ import { ActivityMetrics, processActivityData } from "@/components/activity_metr
|
||||||
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
|
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
|
||||||
import UserDropdown from "@/components/common_components/UserDropdown";
|
import UserDropdown from "@/components/common_components/UserDropdown";
|
||||||
import EntityUsageExportModal from "@/components/EntityUsageExport";
|
import EntityUsageExportModal from "@/components/EntityUsageExport";
|
||||||
import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
|
import { getApiKeyTruncation, getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||||
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
|
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
|
||||||
import { Team } from "@/components/key_team_helpers/key_list";
|
import { Team } from "@/components/key_team_helpers/key_list";
|
||||||
import {
|
import {
|
||||||
|
|
@ -256,6 +256,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
||||||
coversRange: activeAggregated !== null || paginatedResult.coversRange,
|
coversRange: activeAggregated !== null || paginatedResult.coversRange,
|
||||||
cancelled: paginatedResult.cancelled,
|
cancelled: paginatedResult.cancelled,
|
||||||
failed: paginatedResult.failed,
|
failed: paginatedResult.failed,
|
||||||
|
apiKeyTruncation: getApiKeyTruncation(
|
||||||
|
userSpendData.metadata?.api_key_limit,
|
||||||
|
userSpendData.metadata?.total_api_keys,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
const exportBlockedReason = getExportBlockedReason(spendFetchState);
|
const exportBlockedReason = getExportBlockedReason(spendFetchState);
|
||||||
|
|
||||||
|
|
@ -904,7 +908,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
||||||
<ActivityMetrics modelMetrics={modelMetrics} />
|
<ActivityMetrics modelMetrics={modelMetrics} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="keys" keepMounted>
|
<TabsContent value="keys" keepMounted>
|
||||||
<KeyActivityPanel keyMetrics={keyMetrics} />
|
<KeyActivityPanel keyMetrics={keyMetrics} apiKeyTruncation={spendFetchState.apiKeyTruncation} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="mcp" keepMounted>
|
<TabsContent value="mcp" keepMounted>
|
||||||
<ActivityMetrics modelMetrics={mcpServerMetrics} />
|
<ActivityMetrics modelMetrics={mcpServerMetrics} />
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason";
|
import { getApiKeyTruncation, getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason";
|
||||||
|
|
||||||
const state = (overrides: Partial<UsageFetchState> = {}): UsageFetchState => ({
|
const state = (overrides: Partial<UsageFetchState> = {}): UsageFetchState => ({
|
||||||
coversRange: true,
|
coversRange: true,
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
failed: false,
|
failed: false,
|
||||||
|
apiKeyTruncation: undefined,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -31,4 +32,27 @@ describe("getExportBlockedReason", () => {
|
||||||
expect(reason).toMatch(/failed to load/i);
|
expect(reason).toMatch(/failed to load/i);
|
||||||
expect(reason).not.toMatch(/stopped/i);
|
expect(reason).not.toMatch(/stopped/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("blocks when the aggregated endpoint dropped keys, since a per-team CSV would miss them", () => {
|
||||||
|
const reason = getExportBlockedReason(state({ apiKeyTruncation: { limit: 100, total: 3000 } }));
|
||||||
|
|
||||||
|
expect(reason).toMatch(/100 highest-spend keys of 3000/);
|
||||||
|
expect(reason).toMatch(/USAGE_TOP_API_KEYS_LIMIT/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getApiKeyTruncation", () => {
|
||||||
|
it("reports truncation once the proxy saw more keys than it returned", () => {
|
||||||
|
expect(getApiKeyTruncation(100, 101)).toEqual({ limit: 100, total: 101 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays quiet when exactly the cap exists, since every key is on screen", () => {
|
||||||
|
expect(getApiKeyTruncation(100, 100)).toBeUndefined();
|
||||||
|
expect(getApiKeyTruncation(100, 7)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays quiet when the response carries no cap, as the paginated fallback does", () => {
|
||||||
|
expect(getApiKeyTruncation(undefined, undefined)).toBeUndefined();
|
||||||
|
expect(getApiKeyTruncation(100, null)).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,31 @@
|
||||||
|
export interface ApiKeyTruncation {
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UsageFetchState {
|
export interface UsageFetchState {
|
||||||
coversRange: boolean;
|
coversRange: boolean;
|
||||||
cancelled: boolean;
|
cancelled: boolean;
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
|
apiKeyTruncation: ApiKeyTruncation | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getExportBlockedReason = ({ coversRange, cancelled, failed }: UsageFetchState): string | undefined => {
|
export const getApiKeyTruncation = (apiKeyLimit: unknown, totalApiKeys: unknown): ApiKeyTruncation | undefined => {
|
||||||
|
if (typeof apiKeyLimit !== "number" || typeof totalApiKeys !== "number") return undefined;
|
||||||
|
return totalApiKeys > apiKeyLimit ? { limit: apiKeyLimit, total: totalApiKeys } : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getExportBlockedReason = ({
|
||||||
|
coversRange,
|
||||||
|
cancelled,
|
||||||
|
failed,
|
||||||
|
apiKeyTruncation,
|
||||||
|
}: UsageFetchState): string | undefined => {
|
||||||
if (failed) return "Some spend data failed to load, so an export would under-report. Reload the page to try again.";
|
if (failed) return "Some spend data failed to load, so an export would under-report. Reload the page to try again.";
|
||||||
if (cancelled)
|
if (cancelled)
|
||||||
return "Loading was stopped before the whole range arrived, so an export would under-report. Reload the page to load it all.";
|
return "Loading was stopped before the whole range arrived, so an export would under-report. Reload the page to load it all.";
|
||||||
if (!coversRange) return "Spend data is still loading, so an export would under-report. Wait for it to finish.";
|
if (!coversRange) return "Spend data is still loading, so an export would under-report. Wait for it to finish.";
|
||||||
|
if (apiKeyTruncation !== undefined)
|
||||||
|
return `Only the ${apiKeyTruncation.limit} highest-spend keys of ${apiKeyTruncation.total} were loaded, so a per-team export would under-report. Raise USAGE_TOP_API_KEYS_LIMIT on the proxy to load more keys.`;
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -68,4 +68,14 @@ describe("KeyActivityPanel", () => {
|
||||||
expect(screen.getByLabelText("Search keys")).toHaveValue("");
|
expect(screen.getByLabelText("Search keys")).toHaveValue("");
|
||||||
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
|
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says how many keys the proxy left out when only the top spenders were loaded", () => {
|
||||||
|
render(<KeyActivityPanel keyMetrics={keyMetrics} apiKeyTruncation={{ limit: 2, total: 3000 }} />);
|
||||||
|
expect(screen.getByRole("note")).toHaveTextContent("Only the 2 highest-spend keys of 3,000 are loaded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no truncation note when every key is loaded", () => {
|
||||||
|
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||||
|
expect(screen.queryByRole("note")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Search, X } from "lucide-react";
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { ActivityMetrics } from "@/components/activity_metrics";
|
import { ActivityMetrics } from "@/components/activity_metrics";
|
||||||
|
import type { ApiKeyTruncation } from "@/components/EntityUsageExport/exportBlockedReason";
|
||||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
|
||||||
|
|
||||||
import { filterKeyActivity } from "../keyActivityFilter";
|
import { filterKeyActivity } from "../keyActivityFilter";
|
||||||
|
|
@ -10,9 +11,14 @@ import type { ModelActivityData } from "../types";
|
||||||
interface KeyActivityPanelProps {
|
interface KeyActivityPanelProps {
|
||||||
keyMetrics: Record<string, ModelActivityData>;
|
keyMetrics: Record<string, ModelActivityData>;
|
||||||
hidePromptCachingMetrics?: boolean;
|
hidePromptCachingMetrics?: boolean;
|
||||||
|
apiKeyTruncation?: ApiKeyTruncation;
|
||||||
}
|
}
|
||||||
|
|
||||||
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ keyMetrics, hidePromptCachingMetrics = false }) => {
|
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
||||||
|
keyMetrics,
|
||||||
|
hidePromptCachingMetrics = false,
|
||||||
|
apiKeyTruncation,
|
||||||
|
}) => {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]);
|
const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]);
|
||||||
const totalKeys = Object.keys(keyMetrics).length;
|
const totalKeys = Object.keys(keyMetrics).length;
|
||||||
|
|
@ -43,6 +49,12 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ keyMetrics, hidePro
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys
|
Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys
|
||||||
</span>
|
</span>
|
||||||
|
{apiKeyTruncation !== undefined && (
|
||||||
|
<span className="text-sm text-muted-foreground" role="note">
|
||||||
|
Only the {apiKeyTruncation.limit.toLocaleString()} highest-spend keys of{" "}
|
||||||
|
{apiKeyTruncation.total.toLocaleString()} are loaded
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isFiltering && totalKeys > 0 && shownKeys === 0 ? (
|
{isFiltering && totalKeys > 0 && shownKeys === 0 ? (
|
||||||
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
|
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
|
||||||
|
|
|
||||||
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
10
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -27693,6 +27693,11 @@ export interface components {
|
||||||
};
|
};
|
||||||
/** DailySpendMetadata */
|
/** DailySpendMetadata */
|
||||||
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;
|
||||||
/**
|
/**
|
||||||
* Has More
|
* Has More
|
||||||
* @default false
|
* @default false
|
||||||
|
|
@ -27703,6 +27708,11 @@ export interface components {
|
||||||
* @default 1
|
* @default 1
|
||||||
*/
|
*/
|
||||||
page: number;
|
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
|
* Total Api Requests
|
||||||
* @default 0
|
* @default 0
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue