Merge remote-tracking branch 'origin/main' into litellm_ptu_shares_per_team

# Conflicts:
#	litellm/proxy/management_endpoints/team_endpoints.py
#	ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx
#	ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts
#	ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts
#	ui/litellm-dashboard/src/components/EntityUsageExport/types.ts
#	ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts
#	ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts
This commit is contained in:
mateo-berri 2026-10-01 17:35:32 -07:00
commit 5edea75ffd
94 changed files with 14725 additions and 6889 deletions

View file

@ -60,6 +60,7 @@ BACKEND_PATH_PREFIXES: tuple[str, ...] = (
# Tools / agents (registry & policy admin)
"/v1/tool/",
"/v1/agents",
"/agent/daily/activity/",
# Guardrails admin
"/v2/guardrails/",
# MCP server admin + BYOK OAuth flow (UI-initiated) + dynamic per-server endpoints

View file

@ -6,8 +6,6 @@ services:
context: .
dockerfile: docker/Dockerfile.non_root
target: runtime
args:
PROXY_EXTRAS_SOURCE: "local"
depends_on:
- squid
user: "101:101"

View file

@ -3,7 +3,6 @@
# Base images
ARG LITELLM_BUILD_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d
ARG LITELLM_RUNTIME_IMAGE=cgr.dev/chainguard/wolfi-base@sha256:1d95114038f76513a9ace6fca107d5582b08c65981f81f61cb56bf7fd2ef216d
ARG PROXY_EXTRAS_SOURCE=published
ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.11.7@sha256:240fb85ab0f263ef12f492d8476aa3a2e4e1e333f7d67fbdd923d00a506a516a
# Pinned by digest like the other base images; bump explicitly on Node upgrades.
ARG UI_BUILD_IMAGE=node:24.19-alpine3.24@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
@ -44,7 +43,6 @@ COPY ui/litellm-dashboard/ ./
RUN npm run build
FROM $LITELLM_BUILD_IMAGE AS builder
ARG PROXY_EXTRAS_SOURCE
WORKDIR /app
USER root
@ -107,26 +105,14 @@ RUN mkdir -p /var/lib/litellm/ui /var/lib/litellm/assets && \
touch /var/lib/litellm/ui/.litellm_ui_ready
RUN --mount=type=cache,target=/app/.cache/uv,id=litellm-uv-cache \
if [ "$PROXY_EXTRAS_SOURCE" = "published" ]; then \
uv sync --frozen --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
--extra bedrock-realtime \
--python python3.13 \
--no-sources-package litellm-proxy-extras; \
else \
uv sync --frozen --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
--extra bedrock-realtime \
--python python3.13; \
fi
uv sync --frozen --no-default-groups --no-editable \
--extra proxy \
--extra proxy-runtime \
--extra extra_proxy \
--extra semantic-router \
--extra saml \
--extra bedrock-realtime \
--python python3.13
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
npm_config_cache=/root/.npm \
@ -136,7 +122,6 @@ RUN sed -i 's/\r$//' docker/entrypoint.sh && chmod +x docker/entrypoint.sh && \
sed -i 's/\r$//' docker/prod_entrypoint.sh && chmod +x docker/prod_entrypoint.sh
FROM $LITELLM_RUNTIME_IMAGE AS runtime
ARG PROXY_EXTRAS_SOURCE
WORKDIR /app
USER root

View file

@ -27,11 +27,13 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import (
from litellm.litellm_core_utils.prompt_templates.factory import (
THOUGHT_SIGNATURE_SEPARATOR,
)
from litellm.litellm_core_utils.prompt_templates.mid_conversation_system import message_field, parts_of
from litellm.llms.base_llm.base_utils import BaseLLMModelInfo, BaseTokenCounter
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.types.llms.anthropic import (
ANTHROPIC_HOSTED_TOOLS,
ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER,
ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,
ANTHROPIC_OAUTH_BETA_HEADER,
ANTHROPIC_OAUTH_TOKEN_PREFIX,
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,
@ -344,6 +346,18 @@ class AnthropicModelInfo(BaseLLMModelInfo):
return False
return thinking.get("type") in ("adaptive", "enabled") and thinking.get("display") == "updates"
def is_mid_conversation_tool_change_used(self, messages: Sequence[object]) -> bool:
for message in messages:
if message_field(message, "role") != "system":
continue
for block in parts_of(message_field(message, "content")):
if (
message_field(block, "type") in ("tool_addition", "tool_removal")
and message_field(message_field(block, "tool"), "type") == "tool_reference"
):
return True
return False
def is_mid_conversation_output_config_used(self, messages: list[AllMessageValues]) -> bool:
"""
Return if "output_config" is in a message
@ -881,6 +895,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
custom_llm_provider: str,
is_mid_conversation_output_config_used: bool = False,
is_thinking_display_updates_used: bool = False,
is_mid_conversation_tool_change_used: bool = False,
) -> list[str]:
"""
Get list of common beta headers based on the features that are active.
@ -919,7 +934,10 @@ class AnthropicModelInfo(BaseLLMModelInfo):
thinking_display_betas: Final = (
(ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,) if is_thinking_display_updates_used else ()
)
return list(set(betas).union(thinking_display_betas))
tool_change_betas: Final = (
(ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,) if is_mid_conversation_tool_change_used else ()
)
return list(set(betas).union(thinking_display_betas, tool_change_betas))
@staticmethod
def _make_api_key_auth_header(api_key: str, api_base: str | None, use_bearer_for_custom_base: bool = False) -> dict:
@ -953,6 +971,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
use_bearer_for_custom_base: bool = False,
is_mid_conversation_output_config_used: bool = False,
is_thinking_display_updates_used: bool = False,
is_mid_conversation_tool_change_used: bool = False,
) -> dict:
betas: Final = set()
# Anthropic no longer requires the prompt-caching beta header
@ -1010,7 +1029,8 @@ class AnthropicModelInfo(BaseLLMModelInfo):
betas.update(user_anthropic_beta_headers)
all_betas: Final = betas.union(
(ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,) if is_thinking_display_updates_used else ()
(ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,) if is_thinking_display_updates_used else (),
(ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,) if is_mid_conversation_tool_change_used else (),
)
# Don't send any beta headers to Vertex, except web search which is required
@ -1080,6 +1100,7 @@ class AnthropicModelInfo(BaseLLMModelInfo):
file_id_used=file_id_used,
is_mid_conversation_output_config_used=is_mid_conversation_output_config_used,
is_thinking_display_updates_used=self.is_thinking_display_updates_used(optional_params.get("thinking")),
is_mid_conversation_tool_change_used=self.is_mid_conversation_tool_change_used(messages),
web_search_tool_used=web_search_tool_used,
is_vertex_request=optional_params.get("is_vertex_request", False),
user_anthropic_beta_headers=user_anthropic_beta_headers,

View file

@ -12,6 +12,7 @@ from litellm.llms.base_llm.anthropic_messages.transformation import (
from litellm.types.llms.anthropic import (
ANTHROPIC_ADVISOR_TOOL_TYPE,
ANTHROPIC_BETA_HEADER_VALUES,
ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,
AnthropicMessagesRequest,
)
@ -694,7 +695,12 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
if AnthropicModelInfo().is_thinking_display_updates_used(optional_params.get("thinking"))
else ()
)
all_beta_values: Final = beta_values.union(thinking_display_betas)
tool_change_betas: Final = (
(ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,)
if AnthropicModelInfo().is_mid_conversation_tool_change_used(messages)
else ()
)
all_beta_values: Final = beta_values.union(thinking_display_betas, tool_change_betas)
if not all_beta_values:
return headers

View file

@ -549,6 +549,9 @@ class AmazonAnthropicClaudeMessagesConfig(
is_thinking_display_updates_used=anthropic_model_info.is_thinking_display_updates_used(
anthropic_messages_request.get("thinking")
),
is_mid_conversation_tool_change_used=anthropic_model_info.is_mid_conversation_tool_change_used(
outgoing_messages_typed
),
)
beta_set.update(auto_betas)

File diff suppressed because it is too large Load diff

View file

@ -699,6 +699,10 @@ class LiteLLMRoutes(enum.Enum):
KeyManagementRoutes.TEAM_KEY_BULK_UPDATE.value,
KeyManagementRoutes.TEAM_DAILY_ACTIVITY.value,
KeyManagementRoutes.TEAM_DAILY_ACTIVITY_AGGREGATED.value,
"/team/daily/activity/aggregated/keys",
"/team/daily/activity/aggregated/search",
"/team/daily/activity/aggregated/model_top_keys",
"/team/daily/activity/export",
KeyManagementRoutes.SPEND_LOGS.value,
KeyManagementRoutes.SPEND_LOGS_V2.value,
KeyManagementRoutes.KEY_RESET_SPEND.value,
@ -725,6 +729,11 @@ class LiteLLMRoutes(enum.Enum):
"/user/list",
"/user/daily/activity",
"/user/daily/activity/aggregated",
"/user/daily/activity/aggregated/keys",
"/user/daily/activity/aggregated/search",
"/user/daily/activity/aggregated/model_top_keys",
"/user/daily/activity/export",
"/user/daily/activity/aggregated/cache_leakage_keys",
# team
"/team/new",
"/team/update",
@ -742,6 +751,10 @@ class LiteLLMRoutes(enum.Enum):
"/team/permissions_bulk_update",
"/team/daily/activity",
"/team/daily/activity/aggregated",
"/team/daily/activity/aggregated/keys",
"/team/daily/activity/aggregated/search",
"/team/daily/activity/aggregated/model_top_keys",
"/team/daily/activity/export",
"/team/spend/by_user",
# gateway request counts (SGR); deployment-wide, admin-only
"/gateway/daily/activity",
@ -870,6 +883,11 @@ class LiteLLMRoutes(enum.Enum):
# Tag usage endpoints scope internal users to tags produced by
# their own keys in tag_management_endpoints.py.
"/tag/daily/activity",
"/tag/daily/activity/aggregated",
"/tag/daily/activity/aggregated/keys",
"/tag/daily/activity/aggregated/search",
"/tag/daily/activity/aggregated/model_top_keys",
"/tag/daily/activity/export",
"/tag/list",
"/v1/models/{model_id}",
"/models/{model_id}",
@ -894,6 +912,11 @@ class LiteLLMRoutes(enum.Enum):
# Tag usage endpoints scope internal viewers to tags produced by
# their own keys in tag_management_endpoints.py.
"/tag/daily/activity",
"/tag/daily/activity/aggregated",
"/tag/daily/activity/aggregated/keys",
"/tag/daily/activity/aggregated/search",
"/tag/daily/activity/aggregated/model_top_keys",
"/tag/daily/activity/export",
"/tag/list",
]
)
@ -913,6 +936,10 @@ class LiteLLMRoutes(enum.Enum):
"/team/permissions_update",
"/team/daily/activity",
"/team/daily/activity/aggregated",
"/team/daily/activity/aggregated/keys",
"/team/daily/activity/aggregated/search",
"/team/daily/activity/aggregated/model_top_keys",
"/team/daily/activity/export",
"/team/spend/by_user",
"/team/{team_id}/members/me",
# POST/GET the team's logging callbacks, and DELETE one of them. Every
@ -928,9 +955,19 @@ class LiteLLMRoutes(enum.Enum):
"/model/delete",
"/user/daily/activity",
"/user/daily/activity/aggregated",
"/user/daily/activity/aggregated/keys",
"/user/daily/activity/aggregated/search",
"/user/daily/activity/aggregated/model_top_keys",
"/user/daily/activity/export",
"/user/daily/activity/aggregated/cache_leakage_keys",
# Endpoint restricts results to organizations the caller is ORG_ADMIN
# of; a caller who administers none gets an empty result set.
"/organization/daily/activity",
"/organization/daily/activity/aggregated",
"/organization/daily/activity/aggregated/keys",
"/organization/daily/activity/aggregated/search",
"/organization/daily/activity/aggregated/model_top_keys",
"/organization/daily/activity/export",
"/user/available_roles", # read-only role metadata; any authenticated user may read
# Claude Code gateway: the signed-in CLI fetches its managed settings and posts its own telemetry
"/claude_code_gateway/managed/settings",
@ -1009,9 +1046,24 @@ class LiteLLMRoutes(enum.Enum):
"/user/available_users",
"/user/available_roles",
"/user/daily/activity",
"/user/daily/activity/aggregated",
"/user/daily/activity/aggregated/keys",
"/user/daily/activity/aggregated/search",
"/user/daily/activity/aggregated/model_top_keys",
"/user/daily/activity/export",
"/user/daily/activity/aggregated/cache_leakage_keys",
"/team/daily/activity",
"/team/daily/activity/aggregated",
"/team/daily/activity/aggregated/keys",
"/team/daily/activity/aggregated/search",
"/team/daily/activity/aggregated/model_top_keys",
"/team/daily/activity/export",
"/tag/daily/activity",
"/tag/daily/activity/aggregated",
"/tag/daily/activity/aggregated/keys",
"/tag/daily/activity/aggregated/search",
"/tag/daily/activity/aggregated/model_top_keys",
"/tag/daily/activity/export",
"/tag/list",
"/audit",
"/audit/{id}",

View file

@ -13,7 +13,7 @@ import os
import uuid
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import Annotated, Final, TypedDict
from typing import Annotated, Final, NamedTuple, TypedDict
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import ValidationError
@ -47,7 +47,11 @@ from litellm.proxy.agent_endpoints.agent_search import (
global_agent_search_index,
search_agents,
)
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import accessible_agents
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import (
AgentRequestHandler,
UnrestrictedAgentAccess,
accessible_agents,
)
from litellm.proxy.agent_endpoints.identity import reject_legacy_identity
from litellm.proxy.agent_endpoints.identity_store import AgentIdentityStore
from litellm.proxy.agent_endpoints.kill_switch import (
@ -63,7 +67,8 @@ from litellm.proxy.agent_endpoints.managed_identity import raise_identity_failur
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user
from litellm.proxy.management_endpoints.common_daily_activity import get_daily_activity
from litellm.proxy.utils import get_custom_url
from litellm.proxy.utils import PrismaClient, get_custom_url
from litellm.repositories.chunked_in import find_many_in
from litellm.types.agents import (
AgentCard,
AgentConfig,
@ -303,6 +308,73 @@ async def _rank_agents_by_query(
assert_never(outcome)
class _AgentDailyActivityScope(NamedTuple):
agent_ids: tuple[str, ...] | None
agent_metadata: Mapping[str, dict[str, object]]
async def _owned_agent_ids(*, user_id: str | None, prisma_client: PrismaClient) -> frozenset[str]:
if user_id is None:
return frozenset()
owned_records: Final = await agents_table(prisma_client).find_many(where={"created_by": user_id})
return frozenset(agent.agent_id for agent in owned_records)
async def _permitted_daily_activity_agent_ids(
*, user_api_key_dict: UserAPIKeyAuth, prisma_client: PrismaClient
) -> frozenset[str]:
access: Final = await AgentRequestHandler.resolve_agent_access(user_api_key_auth=user_api_key_dict)
if isinstance(access, UnrestrictedAgentAccess):
return await _owned_agent_ids(user_id=user_api_key_dict.user_id, prisma_client=prisma_client)
return access.agent_ids
async def _resolve_daily_activity_agent_ids(
*,
agent_ids: tuple[str, ...] | None,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
) -> tuple[str, ...] | None:
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
if _user_has_admin_view(user_api_key_dict):
return agent_ids
permitted_agent_ids: Final = await _permitted_daily_activity_agent_ids(
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
)
return (
tuple(agent_id for agent_id in agent_ids if agent_id in permitted_agent_ids)
if agent_ids
else tuple(permitted_agent_ids)
)
async def resolve_agent_daily_activity_scope(
*,
agent_ids: tuple[str, ...] | None,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
) -> _AgentDailyActivityScope:
await check_feature_access_for_user(user_api_key_dict, "agents")
resolved_agent_ids: Final = await _resolve_daily_activity_agent_ids(
agent_ids=agent_ids,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
)
agent_records: Final = (
await agents_table(prisma_client).find_many(where={})
if resolved_agent_ids is None
else await find_many_in(agents_table(prisma_client), "agent_id", resolved_agent_ids)
)
agent_metadata: Final[Mapping[str, dict[str, object]]] = MappingProxyType(
{agent.agent_id: {"agent_name": agent.agent_name} for agent in agent_records}
)
return _AgentDailyActivityScope(resolved_agent_ids, agent_metadata)
@router.get(
"/v1/agents",
tags=["[beta] A2A Agents"],
@ -1288,82 +1360,39 @@ async def get_agent_daily_activity(
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
agent_ids_list = agent_ids.split(",") if agent_ids else None
exclude_agent_ids_list: list[str] | None = None
if exclude_agent_ids:
exclude_agent_ids_list = exclude_agent_ids.split(",") if exclude_agent_ids else None
# Without scoping, an empty `agent_ids` query returned every agent's
# spend/token rows on the proxy. Restrict non-admin callers to the
# agents they're permitted to invoke (or that they created), and
# intersect their explicit `agent_ids` filter with the same allowlist.
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import (
AgentRequestHandler,
RestrictedAgentAccess,
UnrestrictedAgentAccess,
requested_agent_ids: Final = tuple(agent_ids.split(",")) if agent_ids else None
exclude_agent_ids_list: Final[list[str] | None] = exclude_agent_ids.split(",") if exclude_agent_ids else None
agent_scope: Final = await resolve_agent_daily_activity_scope(
agent_ids=requested_agent_ids,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
)
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
where_condition: Final[dict[str, object]] = {}
if not _user_has_admin_view(user_api_key_dict):
permitted_agent_ids: list[str] = []
# An unrestricted caller is not "see everything" for activity scoping. Fall
# back to the agents the caller created so they cannot enumerate other
# tenants' agents.
# Guard against `user_id is None`: a literal None in Prisma
# `where={"created_by": None}` resolves to ``created_by IS NULL``
# and would expose every ownerless agent's rows.
match await AgentRequestHandler.resolve_agent_access(user_api_key_auth=user_api_key_dict):
case RestrictedAgentAccess(allowed_agent_ids):
permitted_agent_ids = list(allowed_agent_ids)
case UnrestrictedAgentAccess():
if user_api_key_dict.user_id is not None:
owned_records: Final = await agents_table(prisma_client).find_many(
where={"created_by": user_api_key_dict.user_id}
)
permitted_agent_ids = [a.agent_id for a in owned_records]
if agent_ids_list:
permitted_agent_id_set: Final = set(permitted_agent_ids)
agent_ids_list = [aid for aid in agent_ids_list if aid in permitted_agent_id_set]
else:
agent_ids_list = list(permitted_agent_ids)
# No accessible agents → return an empty page without querying.
if not agent_ids_list:
return SpendAnalyticsPaginatedResponse(
results=[],
metadata=DailySpendMetadata(
total_spend=0.0,
total_prompt_tokens=0,
total_completion_tokens=0,
total_tokens=0,
total_api_requests=0,
total_successful_requests=0,
total_failed_requests=0,
total_cache_read_input_tokens=0,
total_cache_creation_input_tokens=0,
total_compression_saved_tokens=0,
page=page,
total_pages=0,
has_more=False,
),
)
if agent_ids_list:
where_condition["agent_id"] = {"in": list(agent_ids_list)}
agent_records: Final = await agents_table(prisma_client).find_many(where=where_condition)
agent_metadata: Final[Mapping[str, dict[str, object]]] = {
agent.agent_id: {"agent_name": agent.agent_name} for agent in agent_records
}
if agent_scope.agent_ids == ():
return SpendAnalyticsPaginatedResponse(
results=[],
metadata=DailySpendMetadata(
total_spend=0.0,
total_prompt_tokens=0,
total_completion_tokens=0,
total_tokens=0,
total_api_requests=0,
total_successful_requests=0,
total_failed_requests=0,
total_cache_read_input_tokens=0,
total_cache_creation_input_tokens=0,
total_compression_saved_tokens=0,
page=page,
total_pages=0,
has_more=False,
),
)
return await get_daily_activity(
prisma_client=prisma_client,
table_name="litellm_dailyagentspend",
entity_id_field="agent_id",
entity_id=agent_ids_list,
entity_metadata_field=agent_metadata,
entity_id=None if agent_scope.agent_ids is None else list(agent_scope.agent_ids),
entity_metadata_field=agent_scope.agent_metadata,
exclude_entity_ids=exclude_agent_ids_list,
start_date=start_date,
end_date=end_date,

View file

@ -4,10 +4,10 @@ from collections.abc import Set as AbstractSet
from dataclasses import dataclass, replace
from datetime import datetime, timedelta
from types import MappingProxyType
from typing import Final, Protocol
from typing import Final, Literal, NoReturn, Protocol
from fastapi import HTTPException, status
from typing_extensions import ReadOnly, TypedDict
from typing_extensions import ReadOnly, TypedDict, assert_never
from litellm import constants
from litellm._logging import verbose_proxy_logger
@ -45,6 +45,27 @@ from litellm.types.repositories.daily_activity import (
)
@dataclass(frozen=True, slots=True)
class ScopeDenied:
status_code: Literal[403, 404]
reason: str
@dataclass(frozen=True, slots=True)
class InvalidDateRange:
reason: str
def raise_public(error: ScopeDenied | InvalidDateRange) -> NoReturn:
match error:
case ScopeDenied():
raise HTTPException(status_code=error.status_code, detail={"error": error.reason})
case InvalidDateRange():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail={"error": error.reason})
case _:
assert_never(error)
class DailySpendRecord(Protocol):
@property
def date(self) -> str: ...
@ -402,7 +423,7 @@ def update_breakdown_metrics(
return breakdown
def _spend_logs_window(dates: AbstractSet[str | None]) -> tuple[datetime, datetime] | None:
def spend_logs_window(dates: AbstractSet[str | None]) -> tuple[datetime, datetime] | None:
parsed: Final = sorted(day for day in (_parse_spend_date(raw) for raw in dates) if day is not None)
if not parsed:
return None
@ -622,7 +643,7 @@ async def _aggregate_spend_records(
api_key_metadata: Final[Mapping[str, KeyMetadataRow]] = (
await repository.key_metadata(
frozenset(api_keys), _spend_logs_window(frozenset(record.date for record in records))
frozenset(api_keys), spend_logs_window(frozenset(record.date for record in records))
)
if api_keys
else MappingProxyType({})
@ -823,7 +844,7 @@ async def _aggregate_grouping_sets_records(
api_keys: Final[set[str]] = {r.api_key for r in records if r.api_key and r.api_key != PTU_SENTINEL_API_KEY}
api_key_metadata: Final[Mapping[str, KeyMetadataRow]] = (
await repository.key_metadata(frozenset(api_keys), _spend_logs_window(frozenset(r.date for r in records)))
await repository.key_metadata(frozenset(api_keys), spend_logs_window(frozenset(r.date for r in records)))
if api_keys
else MappingProxyType({})
)
@ -990,7 +1011,7 @@ async def get_daily_activity_aggregated(
)
entity_key_metadata: Final[Mapping[str, KeyMetadataRow]] = (
await repository.key_metadata(
entity_api_keys, _spend_logs_window(frozenset(row.date for row in entity_records))
entity_api_keys, spend_logs_window(frozenset(row.date for row in entity_records))
)
if entity_api_keys
else MappingProxyType({})

View file

@ -12,7 +12,8 @@ All /customer management endpoints
#### END-USER/CUSTOMER MANAGEMENT ####
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Final, Protocol, TypeVar, overload
from types import MappingProxyType
from typing import TYPE_CHECKING, Final, NamedTuple, Protocol, TypeVar, overload
import fastapi
from fastapi import APIRouter, Depends, HTTPException, Request
@ -41,6 +42,7 @@ from litellm.proxy.management_helpers.object_permission_utils import (
)
from litellm.proxy.utils import handle_exception_on_proxy
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.chunked_in import find_many_in
from litellm.repositories.table_repositories import EndUserRepository
from litellm.types.proxy.management_endpoints.common_daily_activity import (
SpendAnalyticsPaginatedResponse,
@ -490,6 +492,33 @@ async def new_end_user(
raise handle_exception_on_proxy(e)
class _CustomerDailyActivityScope(NamedTuple):
end_user_ids: tuple[str, ...] | None
end_user_metadata: Mapping[str, dict[str, object]]
async def resolve_customer_daily_activity_scope(
*,
end_user_ids: tuple[str, ...] | None,
prisma_client: "PrismaClient",
) -> _CustomerDailyActivityScope:
end_user_table: Final = _typed_table(EndUserRepository(prisma_client))
end_user_aliases: Final = (
await find_many_in(end_user_table, "user_id", end_user_ids)
if end_user_ids is not None
else await end_user_table.find_many(where={})
)
metadata: Final = MappingProxyType({end_user.user_id: {"alias": end_user.alias} for end_user in end_user_aliases})
return _CustomerDailyActivityScope(end_user_ids, metadata)
def customer_daily_activity_is_admin(user_api_key_dict: UserAPIKeyAuth) -> bool:
return user_api_key_dict.user_role in (
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
)
@router.get(
"/customer/info",
tags=["Customer Management"],
@ -888,10 +917,7 @@ async def get_customer_daily_activity(
"""
Get daily activity for specific organizations or all accessible organizations.
"""
if (
user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN
and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY
):
if not customer_daily_activity_is_admin(user_api_key_dict):
raise HTTPException(
status_code=401,
detail={"error": f"Admin-only endpoint. Your user role={user_api_key_dict.user_role}"},
@ -906,24 +932,22 @@ async def get_customer_daily_activity(
)
# Parse comma-separated ids
end_user_ids_list: Final = end_user_ids.split(",") if end_user_ids else None
end_user_ids_list: Final = tuple(end_user_ids.split(",")) if end_user_ids else None
exclude_end_user_ids_list: list[str] | None = None
if exclude_end_user_ids:
exclude_end_user_ids_list = exclude_end_user_ids.split(",") if exclude_end_user_ids else None
# Fetch organization aliases for metadata
where_condition: Final = dict[str, object]()
if end_user_ids_list:
where_condition["user_id"] = {"in": list(end_user_ids_list)}
end_user_aliases: Final = await _typed_table(EndUserRepository(prisma_client)).find_many(where=where_condition)
customer_scope: Final = await resolve_customer_daily_activity_scope(
end_user_ids=end_user_ids_list,
prisma_client=prisma_client,
)
# Query daily activity for organizations
return await get_daily_activity(
prisma_client=prisma_client,
table_name="litellm_dailyenduserspend",
entity_id_field="end_user_id",
entity_id=end_user_ids_list,
entity_metadata_field={e.user_id: {"alias": e.alias} for e in end_user_aliases},
entity_id=None if customer_scope.end_user_ids is None else list(customer_scope.end_user_ids),
entity_metadata_field=customer_scope.end_user_metadata,
exclude_entity_ids=exclude_end_user_ids_list,
start_date=start_date,
end_date=end_date,

View file

@ -0,0 +1,589 @@
import csv
import io
import json
from collections.abc import AsyncIterator, Mapping, Sequence
from dataclasses import asdict, fields, replace
from datetime import datetime
from typing import Annotated, Final, Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
from litellm import constants
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.common_daily_activity import (
InvalidDateRange,
ScopeDenied,
daily_activity_repository,
get_daily_activity_aggregated,
raise_public,
spend_logs_window,
)
from litellm.proxy.management_endpoints.daily_activity_scopes import (
AGENT_RESOLVER,
CUSTOMER_RESOLVER,
ORGANIZATION_RESOLVER,
TAG_RESOLVER,
TEAM_RESOLVER,
USER_RESOLVER,
EntityQuery,
EntityScopeResolver,
ResolvedScope,
)
from litellm.proxy.management_endpoints.ptu_consumption import with_ptu_consumption
from litellm.proxy.management_endpoints.team_endpoints import aggregated_date_range_error
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
from litellm.proxy.utils import PrismaClient, get_prisma_client_or_throw
from litellm.repositories.daily_activity_repository import DailyActivityRepository
from litellm.router import Router
from litellm.types.proxy.management_endpoints.common_daily_activity import (
CacheLeakageKeysResponse,
DailyActivityKeyPageResponse,
DailyActivityKeySearchResponse,
KeyActivityRow,
KeyMetadata,
KeySpendActivityRow,
KeySpendMetrics,
ModelTopKeysResponse,
SpendAnalyticsPaginatedResponse,
SpendMetrics,
)
from litellm.types.repositories.daily_activity import (
ExportRow,
ExportType,
KeyMetadataRow,
KeySpendRow,
)
router = APIRouter()
def get_daily_activity_prisma_client() -> PrismaClient:
return get_prisma_client_or_throw(CommonProxyErrors.db_not_connected_error.value)
def get_daily_activity_repository() -> DailyActivityRepository:
return daily_activity_repository(get_daily_activity_prisma_client())
def get_daily_activity_llm_router() -> Router | None:
from litellm.proxy.proxy_server import llm_router
return llm_router
def _date_range_error(query: EntityQuery, *, user_aggregated: bool) -> InvalidDateRange | None:
if user_aggregated:
if query.start_date is None or query.end_date is None:
return InvalidDateRange(reason="Please provide start_date and end_date")
return None
range_error: Final[str | None] = aggregated_date_range_error(query.start_date, query.end_date)
return None if range_error is None else InvalidDateRange(reason=range_error)
async def _resolved_scope(
resolver: EntityScopeResolver,
query: EntityQuery,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
*,
user_aggregated: bool,
) -> ResolvedScope:
date_error: Final[InvalidDateRange | None] = _date_range_error(query, user_aggregated=user_aggregated)
if date_error is not None:
raise_public(date_error)
result: ResolvedScope | ScopeDenied = await resolver.resolve(user_api_key_dict, query, prisma_client)
if isinstance(result, ScopeDenied):
raise_public(result)
return result
def _sum_metrics(metrics: Sequence[SpendMetrics]) -> SpendMetrics:
return SpendMetrics(
spend=sum(metric.spend for metric in metrics),
flat_cost=sum(metric.flat_cost for metric in metrics),
prompt_tokens=sum(metric.prompt_tokens for metric in metrics),
completion_tokens=sum(metric.completion_tokens for metric in metrics),
cache_read_input_tokens=sum(metric.cache_read_input_tokens for metric in metrics),
cache_creation_input_tokens=sum(metric.cache_creation_input_tokens for metric in metrics),
compression_saved_tokens=sum(metric.compression_saved_tokens for metric in metrics),
compression_savings_spend=sum(metric.compression_savings_spend for metric in metrics),
prompt_caching_savings_spend=sum(metric.prompt_caching_savings_spend for metric in metrics),
gateway_injected_caching_savings_spend=sum(metric.gateway_injected_caching_savings_spend for metric in metrics),
autorouter_savings_spend=sum(metric.autorouter_savings_spend for metric in metrics),
total_tokens=sum(metric.total_tokens for metric in metrics),
successful_requests=sum(metric.successful_requests for metric in metrics),
failed_requests=sum(metric.failed_requests for metric in metrics),
api_requests=sum(metric.api_requests for metric in metrics),
total_response_time_ms=sum(metric.total_response_time_ms for metric in metrics),
timed_requests=sum(metric.timed_requests for metric in metrics),
)
def _key_metadata(api_key: str, metadata: Mapping[str, KeyMetadataRow]) -> KeyMetadata:
row: Final[KeyMetadataRow | None] = metadata.get(api_key)
if row is None:
return KeyMetadata()
return KeyMetadata(
key_alias=row.key_alias,
team_id=row.team_id,
user_id=row.user_id,
user_email=row.user_email,
key_exists=row.key_exists,
)
def _key_activity_row(row: KeySpendRow, metadata: Mapping[str, KeyMetadataRow]) -> KeySpendActivityRow:
return KeySpendActivityRow(
api_key=row.api_key,
metrics=KeySpendMetrics(
spend=row.spend,
prompt_tokens=row.prompt_tokens,
completion_tokens=row.completion_tokens,
total_tokens=row.total_tokens,
api_requests=row.api_requests,
successful_requests=row.successful_requests,
failed_requests=row.failed_requests,
cache_read_input_tokens=row.cache_read_input_tokens,
cache_creation_input_tokens=row.cache_creation_input_tokens,
),
metadata=_key_metadata(row.api_key, metadata),
)
async def _key_activity_rows(
repository: DailyActivityRepository,
rows: Sequence[KeySpendRow],
resolved_scope: ResolvedScope,
) -> list[KeySpendActivityRow]:
spend_window: Final[tuple[datetime, datetime] | None] = spend_logs_window(
frozenset((resolved_scope.scope.start_date, resolved_scope.scope.end_date))
)
metadata: Final[Mapping[str, KeyMetadataRow]] = await repository.key_metadata(
frozenset(row.api_key for row in rows),
spend_window,
)
return [_key_activity_row(row, metadata) for row in rows]
def _export_filename(
entity: str,
start_date: str,
end_date: str,
export_type: ExportType,
file_format: Literal["csv", "json"],
) -> str:
extension: Final[str] = "csv" if file_format == "csv" else "json"
return f"{entity}-usage-{start_date}-{end_date}-{export_type.value}.{extension}"
def _content_disposition(
entity: str,
start_date: str,
end_date: str,
export_type: ExportType,
file_format: Literal["csv", "json"],
) -> str:
filename: Final = _export_filename(entity, start_date, end_date, export_type, file_format)
return f'attachment; filename="{filename}"'
def _fold_key_metrics(api_key: str, response: SpendAnalyticsPaginatedResponse) -> KeyActivityRow | None:
metrics: Final[tuple[SpendMetrics, ...]] = tuple(
day.breakdown.api_keys[api_key].metrics for day in response.results if api_key in day.breakdown.api_keys
)
if not metrics:
return None
metadata: Final[KeyMetadata] = next(
day.breakdown.api_keys[api_key].metadata for day in response.results if api_key in day.breakdown.api_keys
)
return KeyActivityRow(api_key=api_key, metrics=_sum_metrics(metrics), metadata=metadata)
def _search_rows(keys: Sequence[str], response: SpendAnalyticsPaginatedResponse) -> list[KeyActivityRow]:
return [row for key in keys if (row := _fold_key_metrics(key, response)) is not None]
def _csv_cell(value: object) -> object:
if isinstance(value, str) and value.startswith(("=", "+", "-", "@", "\t", "\r")):
return f"'{value}"
return value
def _csv_row(values: Sequence[object]) -> bytes:
output: Final[io.StringIO] = io.StringIO(newline="")
csv.writer(output, lineterminator="\r\n").writerow(tuple(_csv_cell(value) for value in values))
return output.getvalue().encode()
def _stream_export_rows(
first_row: ExportRow | None,
rows: AsyncIterator[ExportRow],
file_format: Literal["csv", "json"],
) -> AsyncIterator[bytes]:
async def stream() -> AsyncIterator[bytes]:
if file_format == "csv":
yield _csv_row(tuple(field.name for field in fields(ExportRow)))
if first_row is not None:
yield _csv_row(tuple(asdict(first_row).values()))
async for row in rows:
yield _csv_row(tuple(asdict(row).values()))
return
if first_row is None:
yield b"[]"
return
yield b"[" + json.dumps(jsonable_encoder(first_row), separators=(",", ":")).encode()
async for row in rows:
yield b"," + json.dumps(jsonable_encoder(row), separators=(",", ":")).encode()
yield b"]"
return stream()
def _register_aggregated_route(router: APIRouter, resolver: EntityScopeResolver, prefix: str) -> None:
@management_endpoint_wrapper
async def aggregated(
entity_query: Annotated[EntityQuery, Depends(resolver.query)],
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
repository: Annotated[DailyActivityRepository, Depends(get_daily_activity_repository)],
prisma_client: Annotated[PrismaClient, Depends(get_daily_activity_prisma_client)],
llm_router: Annotated[Router | None, Depends(get_daily_activity_llm_router)],
api_key_limit: Annotated[
int, Query(ge=1, le=constants.USAGE_TOP_API_KEYS_MAX)
] = constants.USAGE_TOP_API_KEYS_DEFAULT,
) -> SpendAnalyticsPaginatedResponse:
try:
resolved: ResolvedScope = await _resolved_scope(
resolver,
entity_query,
user_api_key_dict,
prisma_client,
user_aggregated=resolver.entity == "user",
)
activity: Final = await get_daily_activity_aggregated(
repository,
resolved.scope,
entity_metadata_field=resolved.entity_metadata,
include_entity_breakdown=resolver.include_entity_breakdown,
api_key_limit=api_key_limit,
)
return with_ptu_consumption(activity, llm_router) if resolver.entity == "team" else activity
except HTTPException:
raise
except Exception as exc:
verbose_proxy_logger.exception("Daily activity aggregation failed: %s", exc)
raise HTTPException(status_code=500, detail={"error": f"Failed to fetch analytics: {exc}"})
router.add_api_route(
f"{prefix}/daily/activity/aggregated",
aggregated,
methods=["GET"],
name=resolver.operation_names["aggregated"],
tags=list(resolver.tags),
dependencies=(Depends(user_api_key_auth),),
response_model=SpendAnalyticsPaginatedResponse,
include_in_schema=prefix != "/end_user",
)
if resolver.entity == "user":
aggregated.__doc__ = (
"Aggregated analytics for a user's daily activity without pagination.\n"
"Returns the same response shape as the paginated endpoint with page metadata set to single-page.\n\n"
"Reads daily spend records that only ever accumulate and are never affected by budget\n"
"resets. Their total can legitimately exceed the `spend` field returned by\n"
"`/v2/user/info`, which is a running budget counter that every budget reset sets back\n"
"to zero (or to the overage above `max_budget` when `budget_rollover` is enabled)."
)
elif resolver.entity == "team":
aggregated.__doc__ = (
"Aggregated daily activity for teams without pagination, including per-team breakdown.\n\n"
"One SQL GROUPING SETS pass returns every day in the range regardless of row\n"
"volume, so callers never reassemble pages. Same response shape as the\n"
"paginated endpoint with page metadata pinned to a single page.\n\n"
"Args:\n"
" team_ids (Optional[str]): Comma-separated list of team IDs to filter by. If not provided, "
"returns data for all teams.\n"
" start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD).\n"
" end_date (Optional[str]): End date for the activity period (YYYY-MM-DD).\n"
" model (Optional[str]): Filter by model name.\n"
" api_key (Optional[str]): Filter by API key.\n"
" exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude.\n"
" timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's "
"Date.getTimezoneOffset() convention.\n"
"Returns:\n"
" SpendAnalyticsPaginatedResponse: Response containing all daily activity data for the range."
)
def _register_key_page_route(router: APIRouter, resolver: EntityScopeResolver, prefix: str) -> None:
@management_endpoint_wrapper
async def key_page(
entity_query: Annotated[EntityQuery, Depends(resolver.query)],
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
repository: Annotated[DailyActivityRepository, Depends(get_daily_activity_repository)],
prisma_client: Annotated[PrismaClient, Depends(get_daily_activity_prisma_client)],
offset: Annotated[int, Query(ge=0)] = 0,
limit: Annotated[int, Query(ge=1, le=constants.USAGE_KEY_PAGE_MAX)] = constants.USAGE_KEY_PAGE_DEFAULT,
) -> DailyActivityKeyPageResponse:
try:
resolved: Final = await _resolved_scope(
resolver,
entity_query,
user_api_key_dict,
prisma_client,
user_aggregated=False,
)
page: Final = await repository.key_page(resolved.scope, offset=offset, limit=limit)
return DailyActivityKeyPageResponse(
api_keys=await _key_activity_rows(repository, page.rows, resolved),
total_api_keys=page.total_api_keys,
offset=offset,
limit=limit,
)
except HTTPException:
raise
except Exception as exc:
verbose_proxy_logger.exception("Daily activity key page failed: %s", exc)
raise HTTPException(status_code=500, detail={"error": f"Failed to fetch analytics: {exc}"})
router.add_api_route(
f"{prefix}/daily/activity/aggregated/keys",
key_page,
methods=["GET"],
name=resolver.operation_names["key_page"],
tags=list(resolver.tags),
dependencies=(Depends(user_api_key_auth),),
response_model=DailyActivityKeyPageResponse,
include_in_schema=prefix != "/end_user",
)
def _register_search_route(router: APIRouter, resolver: EntityScopeResolver, prefix: str) -> None:
@management_endpoint_wrapper
async def search(
entity_query: Annotated[EntityQuery, Depends(resolver.query)],
search: Annotated[str, Query(min_length=1)],
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
repository: Annotated[DailyActivityRepository, Depends(get_daily_activity_repository)],
prisma_client: Annotated[PrismaClient, Depends(get_daily_activity_prisma_client)],
limit: Annotated[int, Query(ge=1, le=constants.USAGE_KEY_SEARCH_MAX)] = constants.USAGE_KEY_SEARCH_DEFAULT,
) -> DailyActivityKeySearchResponse:
try:
resolved: ResolvedScope = await _resolved_scope(
resolver,
entity_query,
user_api_key_dict,
prisma_client,
user_aggregated=False,
)
keys: tuple[str, ...] = await repository.search_keys(
resolved.scope,
search=search,
limit=limit,
)
if not keys:
return DailyActivityKeySearchResponse(api_keys=[])
search_response: SpendAnalyticsPaginatedResponse = await get_daily_activity_aggregated(
repository,
replace(resolved.scope, api_keys=keys),
include_entity_breakdown=False,
)
return DailyActivityKeySearchResponse(api_keys=_search_rows(keys, search_response))
except HTTPException:
raise
except Exception as exc:
verbose_proxy_logger.exception("Daily activity key search failed: %s", exc)
raise HTTPException(status_code=500, detail={"error": f"Failed to fetch analytics: {exc}"})
router.add_api_route(
f"{prefix}/daily/activity/aggregated/search",
search,
methods=["GET"],
name=resolver.operation_names["search"],
tags=list(resolver.tags),
dependencies=(Depends(user_api_key_auth),),
response_model=DailyActivityKeySearchResponse,
include_in_schema=prefix != "/end_user",
)
def _register_model_top_keys_route(router: APIRouter, resolver: EntityScopeResolver, prefix: str) -> None:
@management_endpoint_wrapper
async def model_top_keys(
entity_query: Annotated[EntityQuery, Depends(resolver.query)],
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
repository: Annotated[DailyActivityRepository, Depends(get_daily_activity_repository)],
prisma_client: Annotated[PrismaClient, Depends(get_daily_activity_prisma_client)],
model_group: Annotated[str, Query(min_length=1)],
by_model_group: Annotated[bool, Query()] = True,
limit: Annotated[int, Query(ge=1, le=constants.USAGE_MODEL_TOP_KEYS_MAX)] = (
constants.USAGE_MODEL_TOP_KEYS_DEFAULT
),
) -> ModelTopKeysResponse:
try:
resolved: ResolvedScope = await _resolved_scope(
resolver,
entity_query,
user_api_key_dict,
prisma_client,
user_aggregated=False,
)
rows: tuple[KeySpendRow, ...] = await repository.model_top_keys(
resolved.scope,
model_group=model_group,
by_model_group=by_model_group,
limit=limit,
)
return ModelTopKeysResponse(
model=model_group,
by_model_group=by_model_group,
api_keys=await _key_activity_rows(repository, rows, resolved),
)
except HTTPException:
raise
except Exception as exc:
verbose_proxy_logger.exception("Daily activity model top keys failed: %s", exc)
raise HTTPException(status_code=500, detail={"error": f"Failed to fetch analytics: {exc}"})
router.add_api_route(
f"{prefix}/daily/activity/aggregated/model_top_keys",
model_top_keys,
methods=["GET"],
name=resolver.operation_names["model_top_keys"],
tags=list(resolver.tags),
dependencies=(Depends(user_api_key_auth),),
response_model=ModelTopKeysResponse,
include_in_schema=prefix != "/end_user",
)
def _register_export_route(router: APIRouter, resolver: EntityScopeResolver, prefix: str) -> None:
@management_endpoint_wrapper
async def export(
entity_query: Annotated[EntityQuery, Depends(resolver.query)],
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
repository: Annotated[DailyActivityRepository, Depends(get_daily_activity_repository)],
prisma_client: Annotated[PrismaClient, Depends(get_daily_activity_prisma_client)],
export_type: Annotated[ExportType, Query()] = ExportType.DAILY,
file_format: Annotated[Literal["csv", "json"], Query(alias="format")] = "csv",
) -> StreamingResponse:
try:
resolved: ResolvedScope = await _resolved_scope(
resolver,
entity_query,
user_api_key_dict,
prisma_client,
user_aggregated=False,
)
rows: Final = repository.export_rows(resolved.scope, export_type=export_type)
first_row: Final = await anext(rows, None)
return StreamingResponse(
_stream_export_rows(first_row, rows, file_format),
media_type="text/csv" if file_format == "csv" else "application/json",
headers={
"Cache-Control": "no-store",
"Content-Disposition": _content_disposition(
resolver.entity,
resolved.scope.start_date,
resolved.scope.end_date,
export_type,
file_format,
),
},
)
except HTTPException:
raise
except Exception as exc:
verbose_proxy_logger.exception("Daily activity export failed: %s", exc)
raise HTTPException(status_code=500, detail={"error": f"Failed to fetch analytics: {exc}"})
router.add_api_route(
f"{prefix}/daily/activity/export",
export,
methods=["GET"],
name=resolver.operation_names["export"],
tags=list(resolver.tags),
dependencies=(Depends(user_api_key_auth),),
response_class=StreamingResponse,
responses={
200: {
"description": "Streamed daily activity export",
"content": {
"text/csv": {"schema": {"type": "string"}},
"application/json": {"schema": {"type": "array", "items": {"type": "object"}}},
},
}
},
include_in_schema=prefix != "/end_user",
)
def _register_cache_leakage_route(router: APIRouter, resolver: EntityScopeResolver, prefix: str) -> None:
@management_endpoint_wrapper
async def cache_leakage_keys(
entity_query: Annotated[EntityQuery, Depends(resolver.query)],
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
repository: Annotated[DailyActivityRepository, Depends(get_daily_activity_repository)],
prisma_client: Annotated[PrismaClient, Depends(get_daily_activity_prisma_client)],
limit: Annotated[
int, Query(ge=1, le=constants.USAGE_CACHE_LEAKAGE_KEYS_MAX)
] = constants.USAGE_CACHE_LEAKAGE_KEYS_DEFAULT,
) -> CacheLeakageKeysResponse:
try:
resolved: ResolvedScope = await _resolved_scope(
resolver,
entity_query,
user_api_key_dict,
prisma_client,
user_aggregated=False,
)
rows: tuple[KeySpendRow, ...] = await repository.cache_leakage_keys(
resolved.scope,
limit=limit,
)
return CacheLeakageKeysResponse(
api_keys=await _key_activity_rows(repository, rows, resolved),
)
except HTTPException:
raise
except Exception as exc:
verbose_proxy_logger.exception("Daily activity cache leakage keys failed: %s", exc)
raise HTTPException(status_code=500, detail={"error": f"Failed to fetch analytics: {exc}"})
router.add_api_route(
f"{prefix}/daily/activity/aggregated/cache_leakage_keys",
cache_leakage_keys,
methods=["GET"],
name=resolver.operation_names["cache_leakage_keys"],
tags=list(resolver.tags),
dependencies=(Depends(user_api_key_auth),),
response_model=CacheLeakageKeysResponse,
include_in_schema=prefix != "/end_user",
)
def register_daily_activity_routes(router: APIRouter, resolver: EntityScopeResolver) -> None:
for prefix in resolver.route_prefixes:
_register_aggregated_route(router, resolver, prefix)
_register_key_page_route(router, resolver, prefix)
_register_search_route(router, resolver, prefix)
_register_model_top_keys_route(router, resolver, prefix)
_register_export_route(router, resolver, prefix)
if resolver.entity == "user":
_register_cache_leakage_route(router, resolver, prefix)
for _resolver in (
USER_RESOLVER,
TEAM_RESOLVER,
TAG_RESOLVER,
ORGANIZATION_RESOLVER,
CUSTOMER_RESOLVER,
AGENT_RESOLVER,
):
register_daily_activity_routes(router, _resolver)

View file

@ -0,0 +1,472 @@
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Literal
from fastapi import Query
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.agent_endpoints.endpoints import resolve_agent_daily_activity_scope
from litellm.proxy.management_endpoints.common_daily_activity import ScopeDenied
from litellm.proxy.management_endpoints.customer_endpoints import (
customer_daily_activity_is_admin,
resolve_customer_daily_activity_scope,
)
from litellm.proxy.management_endpoints.internal_user_endpoints import resolve_user_daily_activity_entity_ids
from litellm.proxy.management_endpoints.organization_endpoints import resolve_organization_daily_activity_scope
from litellm.proxy.management_endpoints.tag_management_endpoints import get_tag_daily_activity_api_key_filter
from litellm.proxy.management_endpoints.team_endpoints import resolve_team_daily_activity_scope
from litellm.proxy.utils import PrismaClient
from litellm.types.repositories.daily_activity import DailyActivityScope, DailyActivityTable
@dataclass(frozen=True, slots=True)
class EntityQuery:
entity_ids: tuple[str, ...] | None
exclude_entity_ids: tuple[str, ...]
api_key: str | None
start_date: str | None
end_date: str | None
model: str | None
timezone_offset_minutes: int | None
include_current_utc_day: bool
@dataclass(frozen=True, slots=True)
class ResolvedScope:
scope: DailyActivityScope
entity_metadata: Mapping[str, dict[str, object]] | None
Entity = Literal["user", "team", "tag", "organization", "customer", "agent"]
EntityScopeResolution = ResolvedScope | ScopeDenied
EntityScopeQuery = Callable[..., EntityQuery]
EntityScopeResolve = Callable[[UserAPIKeyAuth, EntityQuery, PrismaClient], Awaitable[EntityScopeResolution]]
OperationNames = Mapping[str, str]
@dataclass(frozen=True, slots=True)
class EntityScopeResolver:
entity: Entity
table: DailyActivityTable
entity_id_field: str
route_prefixes: tuple[str, ...]
tags: tuple[str, ...]
query: EntityScopeQuery
resolve: EntityScopeResolve
include_entity_breakdown: bool
operation_names: OperationNames
def _query_ids(value: str | None) -> tuple[str, ...] | None:
return tuple(value.split(",")) if value else None
def _query_excluded_ids(value: str | None) -> tuple[str, ...]:
return tuple(value.split(",")) if value else ()
def _build_scope(
resolver: EntityScopeResolver,
query: EntityQuery,
entity_ids: Sequence[str] | None,
exclude_entity_ids: Sequence[str],
api_key_filter: str | Sequence[str] | None,
entity_metadata: Mapping[str, dict[str, object]] | None,
) -> ResolvedScope:
start_date: Final[str] = query.start_date or ""
end_date: Final[str] = query.end_date or ""
api_keys: Final[tuple[str, ...] | None] = (
None
if api_key_filter is None or api_key_filter == ""
else (api_key_filter,)
if isinstance(api_key_filter, str)
else tuple(api_key_filter)
)
return ResolvedScope(
scope=DailyActivityScope(
table=resolver.table,
entity_id_field=resolver.entity_id_field,
entity_ids=None if entity_ids is None else tuple(entity_ids),
exclude_entity_ids=tuple(exclude_entity_ids),
api_keys=api_keys,
start_date=start_date,
end_date=end_date,
model=query.model,
timezone_offset_minutes=query.timezone_offset_minutes,
include_current_utc_day=query.include_current_utc_day,
),
entity_metadata=entity_metadata,
)
async def _resolve_user(
user_api_key_dict: UserAPIKeyAuth, query: EntityQuery, prisma_client: PrismaClient
) -> EntityScopeResolution:
entity_ids: Final = resolve_user_daily_activity_entity_ids(
user_id=query.entity_ids[0] if query.entity_ids is not None else None,
user_api_key_dict=user_api_key_dict,
)
if isinstance(entity_ids, ScopeDenied):
return entity_ids
return _build_scope(
USER_RESOLVER,
query,
entity_ids,
query.exclude_entity_ids,
query.api_key,
None,
)
async def _resolve_team(
user_api_key_dict: UserAPIKeyAuth, query: EntityQuery, prisma_client: PrismaClient
) -> EntityScopeResolution:
from litellm.proxy.proxy_server import proxy_logging_obj, user_api_key_cache
team_scope: Final = await resolve_team_daily_activity_scope(
team_ids=",".join(query.entity_ids) if query.entity_ids is not None else None,
exclude_team_ids=",".join(query.exclude_entity_ids) if query.exclude_entity_ids else None,
api_key=query.api_key,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
return _build_scope(
TEAM_RESOLVER,
query,
team_scope.team_ids,
team_scope.exclude_team_ids or (),
team_scope.api_key_filter,
team_scope.team_alias_metadata,
)
async def _resolve_tag(
user_api_key_dict: UserAPIKeyAuth, query: EntityQuery, prisma_client: PrismaClient
) -> EntityScopeResolution:
api_key_filter: Final = await get_tag_daily_activity_api_key_filter(
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
requested_api_key=query.api_key,
)
return _build_scope(
TAG_RESOLVER,
query,
query.entity_ids,
query.exclude_entity_ids,
api_key_filter,
None,
)
async def _resolve_organization(
user_api_key_dict: UserAPIKeyAuth, query: EntityQuery, prisma_client: PrismaClient
) -> EntityScopeResolution:
org_scope: Final = await resolve_organization_daily_activity_scope(
organization_ids=query.entity_ids,
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
)
return _build_scope(
ORGANIZATION_RESOLVER,
query,
org_scope.organization_ids,
query.exclude_entity_ids,
query.api_key,
org_scope.organization_metadata,
)
async def _resolve_customer(
user_api_key_dict: UserAPIKeyAuth, query: EntityQuery, prisma_client: PrismaClient
) -> EntityScopeResolution:
if not customer_daily_activity_is_admin(user_api_key_dict):
return ScopeDenied(403, f"Admin-only endpoint. Your user role={user_api_key_dict.user_role}")
customer_scope: Final = await resolve_customer_daily_activity_scope(
end_user_ids=query.entity_ids,
prisma_client=prisma_client,
)
return _build_scope(
CUSTOMER_RESOLVER,
query,
customer_scope.end_user_ids,
query.exclude_entity_ids,
query.api_key,
customer_scope.end_user_metadata,
)
async def _resolve_agent(
user_api_key_dict: UserAPIKeyAuth, query: EntityQuery, prisma_client: PrismaClient
) -> EntityScopeResolution:
agent_scope: Final = await resolve_agent_daily_activity_scope(
agent_ids=query.entity_ids,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
)
return _build_scope(
AGENT_RESOLVER,
query,
agent_scope.agent_ids,
query.exclude_entity_ids,
query.api_key,
agent_scope.agent_metadata,
)
def _user_query(
start_date: str | None = Query(default=None, description="Start date in YYYY-MM-DD format"),
end_date: str | None = Query(default=None, description="End date in YYYY-MM-DD format"),
model: str | None = Query(default=None, description="Filter by specific model"),
api_key: str | None = Query(default=None, description="Filter by specific API key"),
user_id: str | None = Query(
default=None,
description="Filter by specific user ID. Admins can filter by any user or omit for global view. "
"Non-admins must provide their own user_id.",
),
timezone: int | None = Query(
default=None,
description="Timezone offset in minutes from UTC (e.g., 480 for PST). "
"Matches JavaScript's Date.getTimezoneOffset() convention.",
),
include_current_utc_day: bool = Query(
default=False,
description="When the range ends on the caller's current local day, extend it to "
"today's UTC bucket so spend written after the caller's local midnight (in UTC "
"terms) is included. Requires the timezone parameter. Historical ranges are "
"never extended.",
),
) -> EntityQuery:
return EntityQuery(
entity_ids=(user_id,) if user_id is not None else None,
exclude_entity_ids=(),
api_key=api_key,
start_date=start_date,
end_date=end_date,
model=model,
timezone_offset_minutes=timezone,
include_current_utc_day=include_current_utc_day,
)
def _team_query(
team_ids: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
model: str | None = None,
api_key: str | None = None,
exclude_team_ids: str | None = None,
timezone: int | None = None,
) -> EntityQuery:
return EntityQuery(
entity_ids=_query_ids(team_ids),
exclude_entity_ids=_query_excluded_ids(exclude_team_ids),
api_key=api_key,
start_date=start_date,
end_date=end_date,
model=model,
timezone_offset_minutes=timezone,
include_current_utc_day=False,
)
def _tag_query(
start_date: str | None = None,
end_date: str | None = None,
model: str | None = None,
api_key: str | None = None,
tags: str | None = None,
timezone: int | None = None,
) -> EntityQuery:
return EntityQuery(
entity_ids=_query_ids(tags),
exclude_entity_ids=(),
api_key=api_key,
start_date=start_date,
end_date=end_date,
model=model,
timezone_offset_minutes=timezone,
include_current_utc_day=False,
)
def _organization_query(
organization_ids: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
model: str | None = None,
api_key: str | None = None,
exclude_organization_ids: str | None = None,
timezone: int | None = None,
) -> EntityQuery:
return EntityQuery(
entity_ids=_query_ids(organization_ids),
exclude_entity_ids=_query_excluded_ids(exclude_organization_ids),
api_key=api_key,
start_date=start_date,
end_date=end_date,
model=model,
timezone_offset_minutes=timezone,
include_current_utc_day=False,
)
def _customer_query(
end_user_ids: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
model: str | None = None,
api_key: str | None = None,
exclude_end_user_ids: str | None = None,
timezone: int | None = None,
) -> EntityQuery:
return EntityQuery(
entity_ids=_query_ids(end_user_ids),
exclude_entity_ids=_query_excluded_ids(exclude_end_user_ids),
api_key=api_key,
start_date=start_date,
end_date=end_date,
model=model,
timezone_offset_minutes=timezone,
include_current_utc_day=False,
)
def _agent_query(
agent_ids: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
model: str | None = None,
api_key: str | None = None,
exclude_agent_ids: str | None = None,
timezone: int | None = None,
) -> EntityQuery:
return EntityQuery(
entity_ids=_query_ids(agent_ids),
exclude_entity_ids=_query_excluded_ids(exclude_agent_ids),
api_key=api_key,
start_date=start_date,
end_date=end_date,
model=model,
timezone_offset_minutes=timezone,
include_current_utc_day=False,
)
USER_RESOLVER = EntityScopeResolver(
entity="user",
table=DailyActivityTable.USER,
entity_id_field="user_id",
route_prefixes=("/user",),
tags=("Budget & Spend Tracking", "Internal User management"),
query=_user_query,
resolve=_resolve_user,
include_entity_breakdown=False,
operation_names=MappingProxyType(
{
"aggregated": "get_user_daily_activity_aggregated",
"search": "get_user_daily_activity_aggregated_search",
"key_page": "get_user_daily_activity_aggregated_keys",
"model_top_keys": "get_user_daily_activity_model_top_keys",
"export": "get_user_daily_activity_export",
"cache_leakage_keys": "get_user_daily_activity_cache_leakage_keys",
}
),
)
TEAM_RESOLVER = EntityScopeResolver(
entity="team",
table=DailyActivityTable.TEAM,
entity_id_field="team_id",
route_prefixes=("/team",),
tags=("team management",),
query=_team_query,
resolve=_resolve_team,
include_entity_breakdown=True,
operation_names=MappingProxyType(
{
"aggregated": "get_team_daily_activity_aggregated",
"search": "get_team_daily_activity_aggregated_search",
"key_page": "get_team_daily_activity_aggregated_keys",
"model_top_keys": "get_team_daily_activity_model_top_keys",
"export": "get_team_daily_activity_export",
}
),
)
TAG_RESOLVER = EntityScopeResolver(
entity="tag",
table=DailyActivityTable.TAG,
entity_id_field="tag",
route_prefixes=("/tag",),
tags=("tag management",),
query=_tag_query,
resolve=_resolve_tag,
include_entity_breakdown=True,
operation_names=MappingProxyType(
{
"aggregated": "get_tag_daily_activity_aggregated",
"search": "get_tag_daily_activity_aggregated_search",
"key_page": "get_tag_daily_activity_aggregated_keys",
"model_top_keys": "get_tag_daily_activity_model_top_keys",
"export": "get_tag_daily_activity_export",
}
),
)
ORGANIZATION_RESOLVER = EntityScopeResolver(
entity="organization",
table=DailyActivityTable.ORGANIZATION,
entity_id_field="organization_id",
route_prefixes=("/organization",),
tags=("organization management",),
query=_organization_query,
resolve=_resolve_organization,
include_entity_breakdown=True,
operation_names=MappingProxyType(
{
"aggregated": "get_organization_daily_activity_aggregated",
"search": "get_organization_daily_activity_aggregated_search",
"key_page": "get_organization_daily_activity_aggregated_keys",
"model_top_keys": "get_organization_daily_activity_model_top_keys",
"export": "get_organization_daily_activity_export",
}
),
)
CUSTOMER_RESOLVER = EntityScopeResolver(
entity="customer",
table=DailyActivityTable.CUSTOMER,
entity_id_field="end_user_id",
route_prefixes=("/customer", "/end_user"),
tags=("Customer Management",),
query=_customer_query,
resolve=_resolve_customer,
include_entity_breakdown=True,
operation_names=MappingProxyType(
{
"aggregated": "get_customer_daily_activity_aggregated",
"search": "get_customer_daily_activity_aggregated_search",
"key_page": "get_customer_daily_activity_aggregated_keys",
"model_top_keys": "get_customer_daily_activity_model_top_keys",
"export": "get_customer_daily_activity_export",
}
),
)
AGENT_RESOLVER = EntityScopeResolver(
entity="agent",
table=DailyActivityTable.AGENT,
entity_id_field="agent_id",
route_prefixes=("/agent",),
tags=("Agent Management",),
query=_agent_query,
resolve=_resolve_agent,
include_entity_breakdown=True,
operation_names=MappingProxyType(
{
"aggregated": "get_agent_daily_activity_aggregated",
"search": "get_agent_daily_activity_aggregated_search",
"key_page": "get_agent_daily_activity_aggregated_keys",
"model_top_keys": "get_agent_daily_activity_model_top_keys",
"export": "get_agent_daily_activity_export",
}
),
)

View file

@ -54,10 +54,9 @@ 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,
ScopeDenied,
get_daily_activity,
get_daily_activity_aggregated,
raise_public,
)
from litellm.proxy.management_endpoints.common_utils import (
_user_has_admin_view,
@ -2864,6 +2863,18 @@ async def ui_view_users(
# Using shared metric helper implementations from common_daily_activity
def resolve_user_daily_activity_entity_ids(
*, user_id: str | None, user_api_key_dict: UserAPIKeyAuth
) -> tuple[str, ...] | None | ScopeDenied:
if _user_has_admin_view(user_api_key_dict):
return (user_id,) if user_id is not None else None
caller_user_id: Final = require_caller_user_id_for_non_admin(user_api_key_dict)
if user_id is not None and user_id != caller_user_id:
return ScopeDenied(403, "Non-admin users can only view their own spend data.")
return (caller_user_id,)
async def _resolve_user_email_metadata(
prisma_client: "PrismaClient", records: Sequence[DailySpendRecord]
) -> dict[str, dict]:
@ -2958,20 +2969,13 @@ async def get_user_daily_activity(
)
try:
is_admin: Final = _user_has_admin_view(user_api_key_dict)
if is_admin:
entity_id = user_id # None means global view, otherwise filter by user
else:
caller_user_id: Final = require_caller_user_id_for_non_admin(user_api_key_dict)
if user_id is None:
user_id = caller_user_id
if user_id != caller_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "Non-admin users can only view their own spend data."},
)
entity_id = user_id
resolved_entity_ids: Final = resolve_user_daily_activity_entity_ids(
user_id=user_id,
user_api_key_dict=user_api_key_dict,
)
if isinstance(resolved_entity_ids, ScopeDenied):
raise_public(resolved_entity_ids)
entity_id: Final[str | None] = resolved_entity_ids[0] if resolved_entity_ids is not None else None
return await get_daily_activity(
prisma_client=prisma_client,
@ -2998,112 +3002,3 @@ async def get_user_daily_activity(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": f"Failed to fetch analytics: {e}"},
)
@router.get(
"/user/daily/activity/aggregated",
tags=["Budget & Spend Tracking", "Internal User management"],
dependencies=[Depends(user_api_key_auth)],
response_model=SpendAnalyticsPaginatedResponse,
)
@management_endpoint_wrapper
async def get_user_daily_activity_aggregated(
start_date: str | None = fastapi.Query(
default=None,
description="Start date in YYYY-MM-DD format",
),
end_date: str | None = fastapi.Query(
default=None,
description="End date in YYYY-MM-DD format",
),
model: str | None = fastapi.Query(
default=None,
description="Filter by specific model",
),
api_key: str | None = fastapi.Query(
default=None,
description="Filter by specific API key",
),
user_id: str | None = fastapi.Query(
default=None,
description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.",
),
timezone: int | None = fastapi.Query(
default=None,
description="Timezone offset in minutes from UTC (e.g., 480 for PST). "
"Matches JavaScript's Date.getTimezoneOffset() convention.",
),
include_current_utc_day: bool = fastapi.Query(
default=False,
description="When the range ends on the caller's current local day, extend it to "
"today's UTC bucket so spend written after the caller's local midnight (in UTC "
"terms) is included. Requires the timezone parameter. Historical ranges are "
"never extended.",
),
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
) -> SpendAnalyticsPaginatedResponse:
"""
Aggregated analytics for a user's daily activity without pagination.
Returns the same response shape as the paginated endpoint with page metadata set to single-page.
Reads daily spend records that only ever accumulate and are never affected by budget
resets. Their total can legitimately exceed the `spend` field returned by
`/v2/user/info`, which is a running budget counter that every budget reset sets back
to zero (or to the overage above `max_budget` when `budget_rollover` is enabled).
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
if start_date is None or end_date is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"error": "Please provide start_date and end_date"},
)
try:
is_admin: Final = _user_has_admin_view(user_api_key_dict)
if is_admin:
entity_id = user_id # None means global view, otherwise filter by user
else:
caller_user_id: Final = require_caller_user_id_for_non_admin(user_api_key_dict)
if user_id is None:
user_id = caller_user_id
if user_id != caller_user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={"error": "Non-admin users can only view their own spend data."},
)
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(
repository,
scope,
)
except HTTPException:
raise
except Exception as e:
verbose_proxy_logger.exception("/user/daily/activity/aggregated: Exception occured - %s", e)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": f"Failed to fetch analytics: {e}"},
)

View file

@ -14,10 +14,12 @@ Endpoints for /organization operations
#### ORGANIZATION MANAGEMENT ####
from collections.abc import Mapping, Sequence
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
Annotated,
Final,
NamedTuple,
Protocol,
cast, # noqa: TID251 # prisma types Json columns as fields.Json but reads back plain python values
overload,
@ -62,6 +64,7 @@ from litellm.proxy.management_helpers.utils import (
)
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.chunked_in import find_many_in
from litellm.repositories.object_permission_repository import ObjectPermissionRepository
from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.table_repositories import OrganizationMembershipRepository
@ -583,43 +586,23 @@ async def get_organization_daily_activity(
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
# Parse comma-separated ids
org_ids_list = organization_ids.split(",") if organization_ids else None
org_ids: Final = tuple(organization_ids.split(",")) if organization_ids else None
exclude_org_ids_list: list[str] | None = None
if exclude_organization_ids:
exclude_org_ids_list = exclude_organization_ids.split(",") if exclude_organization_ids else None
# Restrict non-proxy-admins to only organizations where they are org_admin
if not _user_has_admin_view(user_api_key_dict):
memberships: Final = await _table(OrganizationMembershipRepository(prisma_client)).find_many(
where={"user_id": user_api_key_dict.user_id}
)
admin_org_ids = [m.organization_id for m in memberships if m.user_role == LitellmUserRoles.ORG_ADMIN.value]
if org_ids_list is None:
# Default to orgs where user is org_admin
org_ids_list = admin_org_ids
else:
# Ensure user is org_admin for all requested orgs
for org_id in org_ids_list:
if org_id not in admin_org_ids:
raise HTTPException(
status_code=403,
detail={"error": f"User is not org_admin for Organization= {org_id}."},
)
org_scope: Final = await resolve_organization_daily_activity_scope(
organization_ids=org_ids,
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
)
# Fetch organization aliases for metadata
where_condition: Final = _STR_OBJECT_DICT_ADAPTER.validate_python({})
if org_ids_list is not None:
where_condition["organization_id"] = {"in": list(org_ids_list)}
org_aliases: Final = await _table(OrganizationRepository(prisma_client)).find_many(where=where_condition)
# Query daily activity for organizations
return await get_daily_activity(
prisma_client=prisma_client,
table_name="litellm_dailyorganizationspend",
entity_id_field="organization_id",
entity_id=org_ids_list,
entity_metadata_field={o.organization_id: {"organization_alias": o.organization_alias} for o in org_aliases},
entity_id=None if org_scope.organization_ids is None else list(org_scope.organization_ids),
entity_metadata_field=org_scope.organization_metadata,
exclude_entity_ids=exclude_org_ids_list,
start_date=start_date,
end_date=end_date,
@ -630,6 +613,56 @@ async def get_organization_daily_activity(
)
class _OrganizationDailyActivityScope(NamedTuple):
organization_ids: tuple[str, ...] | None
organization_metadata: Mapping[str, dict[str, object]]
async def resolve_organization_daily_activity_scope(
*,
organization_ids: tuple[str, ...] | None,
prisma_client: PrismaClient,
user_api_key_dict: UserAPIKeyAuth,
) -> _OrganizationDailyActivityScope:
is_admin: Final = _user_has_admin_view(user_api_key_dict)
memberships: Final = (
await _table(OrganizationMembershipRepository(prisma_client)).find_many(
where={"user_id": user_api_key_dict.user_id}
)
if not is_admin
else ()
)
admin_organization_ids: Final = tuple(
membership.organization_id
for membership in memberships
if membership.user_role == LitellmUserRoles.ORG_ADMIN.value
)
if not is_admin and organization_ids is not None:
for organization_id in organization_ids:
if organization_id not in admin_organization_ids:
raise HTTPException(
status_code=403,
detail={"error": f"User is not org_admin for Organization= {organization_id}."},
)
resolved_organization_ids: Final[tuple[str, ...] | None] = (
organization_ids if is_admin or organization_ids is not None else admin_organization_ids
)
organization_table: Final = _table(OrganizationRepository(prisma_client))
organization_rows: Final = (
await find_many_in(organization_table, "organization_id", resolved_organization_ids)
if resolved_organization_ids is not None
else await organization_table.find_many(where={})
)
metadata: Final = MappingProxyType(
{
organization.organization_id: {"organization_alias": organization.organization_alias}
for organization in organization_rows
}
)
return _OrganizationDailyActivityScope(resolved_organization_ids, metadata)
async def _set_object_permission(
data: NewOrganizationRequest,
prisma_client: PrismaClient | None,

View file

@ -9,7 +9,10 @@ from collections.abc import Callable
from types import MappingProxyType
from typing import Final
from litellm.litellm_core_utils.ptu_pricing import is_ptu_cost_attribution_enabled
from litellm.llms.azure.ptu_capacity import PTUCapacity, normalized_tokens, ptu_hours
from litellm.router import Router
from litellm.router_utils.ptu_shares import model_group_deployments, model_group_ptu_capacity
from litellm.types.proxy.management_endpoints.common_daily_activity import (
DailySpendData,
MetricWithMetadata,
@ -93,3 +96,18 @@ def attach_ptu_hours(
}
)
)
def with_ptu_consumption(
activity: SpendAnalyticsPaginatedResponse, llm_router: Router | None
) -> SpendAnalyticsPaginatedResponse:
"""``activity`` with PTU-hours attached from the router's sized model groups, untouched while
PTU cost attribution is off or no router is loaded."""
if llm_router is None or not is_ptu_cost_attribution_enabled():
return activity
return attach_ptu_hours(
activity,
lambda model_group: model_group_ptu_capacity(
model_group_deployments(llm_router.get_model_list() or (), model_group)
),
)

View file

@ -190,7 +190,7 @@ async def _get_tag_list_scope(
return {"api_key": {"in": scoped_api_keys}}
async def _get_tag_daily_activity_api_key_filter(
async def get_tag_daily_activity_api_key_filter(
prisma_client: "PrismaClient",
user_api_key_dict: UserAPIKeyAuth,
requested_api_key: str | None,
@ -757,7 +757,7 @@ async def get_tag_daily_activity(
# Convert comma-separated tags string to list if provided
tag_list: Final = tags.split(",") if tags else None
scoped_api_key_filter: Final = await _get_tag_daily_activity_api_key_filter(
scoped_api_key_filter: Final = await get_tag_daily_activity_api_key_filter(
prisma_client=prisma_client,
user_api_key_dict=user_api_key_dict,
requested_api_key=api_key,

View file

@ -40,7 +40,6 @@ import litellm
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
from litellm.integrations.prometheus import PrometheusLogger
from litellm.litellm_core_utils.ptu_pricing import is_ptu_cost_attribution_enabled
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy._types import (
UI_TEAM_ID,
@ -125,11 +124,6 @@ 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 (
_check_disable_global_guardrails_caller_permission,
_check_passthrough_routes_caller_permission,
@ -145,7 +139,7 @@ from litellm.proxy.management_endpoints.common_utils import (
from litellm.proxy.management_endpoints.organization_endpoints import (
add_member_to_organization,
)
from litellm.proxy.management_endpoints.ptu_consumption import attach_ptu_hours
from litellm.proxy.management_endpoints.ptu_consumption import with_ptu_consumption
from litellm.proxy.management_endpoints.router_weights import validate_router_settings_weights
from litellm.proxy.management_endpoints.tag_management_endpoints import (
get_daily_activity,
@ -197,7 +191,6 @@ from litellm.repositories.verification_token_repository import (
VerificationTokenRepository,
)
from litellm.router import Router
from litellm.router_utils.ptu_shares import model_group_deployments, model_group_ptu_capacity
from litellm.types.proxy.auth.auth_checks import UserNotFoundError
from litellm.types.proxy.management_endpoints.common_daily_activity import (
SpendAnalyticsPaginatedResponse,
@ -6526,7 +6519,7 @@ class _TeamDailyActivityScope(NamedTuple):
api_key_filter: str | list[str] | None # mutable-ok: downstream daily-activity signatures take str | list unions
async def _resolve_team_daily_activity_scope(
async def resolve_team_daily_activity_scope(
*,
team_ids: str | None,
exclude_team_ids: str | None,
@ -6609,11 +6602,14 @@ async def _resolve_team_daily_activity_scope(
user_api_keys = [key.token for key in user_keys if key.token]
# If user has no API keys, return empty result
if not user_api_keys:
user_api_keys = [""] # Use empty string to ensure no matches
user_api_keys = []
# If api_key parameter is provided, use it; otherwise use user_api_keys if set
final_api_key_filter: str | list[str] | None = api_key
if final_api_key_filter is None and user_api_keys is not None:
final_api_key_filter: str | list[str] | None
if user_api_keys is None:
final_api_key_filter = api_key
elif api_key:
final_api_key_filter = api_key if api_key in user_api_keys else []
else:
final_api_key_filter = user_api_keys
return _TeamDailyActivityScope(
@ -6624,19 +6620,6 @@ async def _resolve_team_daily_activity_scope(
)
def _with_ptu_consumption(
activity: SpendAnalyticsPaginatedResponse, llm_router: Router | None
) -> SpendAnalyticsPaginatedResponse:
if llm_router is None or not is_ptu_cost_attribution_enabled():
return activity
return attach_ptu_hours(
activity,
lambda model_group: model_group_ptu_capacity(
model_group_deployments(llm_router.get_model_list() or (), model_group)
),
)
@router.get(
"/team/daily/activity",
response_model=SpendAnalyticsPaginatedResponse,
@ -6678,7 +6661,7 @@ async def get_team_daily_activity(
if prisma_client is None:
raise _daily_activity_error(status_code=500, message=CommonProxyErrors.db_not_connected_error.value)
scope: Final = await _resolve_team_daily_activity_scope(
scope: Final = await resolve_team_daily_activity_scope(
team_ids=team_ids,
exclude_team_ids=exclude_team_ids,
api_key=api_key,
@ -6702,13 +6685,13 @@ async def get_team_daily_activity(
page=page,
page_size=page_size,
)
return _with_ptu_consumption(activity, llm_router)
return with_ptu_consumption(activity, llm_router)
_MAX_AGGREGATED_RANGE_DAYS: Final = 400
def _aggregated_date_range_error(start_date: str | None, end_date: str | None) -> str | None:
def aggregated_date_range_error(start_date: str | None, end_date: str | None) -> str | None:
"""The aggregated endpoint has no pagination to bound its work, so malformed
dates and ranges wider than the UI ever requests are rejected before querying."""
if start_date is None or end_date is None:
@ -6725,84 +6708,6 @@ def _aggregated_date_range_error(start_date: str | None, end_date: str | None) -
return None
@router.get(
"/team/daily/activity/aggregated",
response_model=SpendAnalyticsPaginatedResponse,
tags=["team management"],
)
async def get_team_daily_activity_aggregated(
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
team_ids: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
model: str | None = None,
api_key: str | None = None,
exclude_team_ids: str | None = None,
timezone: int | None = None,
):
"""
Aggregated daily activity for teams without pagination, including per-team breakdown.
One SQL GROUPING SETS pass returns every day in the range regardless of row
volume, so callers never reassemble pages. Same response shape as the
paginated endpoint with page metadata pinned to a single page.
Args:
team_ids (Optional[str]): Comma-separated list of team IDs to filter by. If not provided, returns data for all teams.
start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD).
end_date (Optional[str]): End date for the activity period (YYYY-MM-DD).
model (Optional[str]): Filter by model name.
api_key (Optional[str]): Filter by API key.
exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude.
timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset() convention.
Returns:
SpendAnalyticsPaginatedResponse: Response containing all daily activity data for the range.
"""
from litellm.proxy.proxy_server import (
llm_router,
prisma_client,
proxy_logging_obj,
user_api_key_cache,
)
if prisma_client is None:
raise _daily_activity_error(status_code=500, message=CommonProxyErrors.db_not_connected_error.value)
range_error: Final = _aggregated_date_range_error(start_date, end_date)
if range_error is not None:
raise _daily_activity_error(status_code=400, message=range_error)
scope: Final = await _resolve_team_daily_activity_scope(
team_ids=team_ids,
exclude_team_ids=exclude_team_ids,
api_key=api_key,
user_api_key_dict=user_api_key_dict,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
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,
)
activity: Final = await get_daily_activity_aggregated(
repository,
activity_scope,
entity_metadata_field=scope.team_alias_metadata,
include_entity_breakdown=True,
)
return _with_ptu_consumption(activity, llm_router)
def _team_user_spend_sql(*, team_count: int, restrict_to_user: bool) -> str:
team_placeholders: Final = ", ".join(f"${i}" for i in range(3, 3 + team_count))
user_clause: Final = f' AND sl."user" = ${3 + team_count}' if restrict_to_user else ""
@ -6870,14 +6775,14 @@ async def get_team_spend_by_user(
if prisma_client is None:
raise _daily_activity_error(status_code=500, message=CommonProxyErrors.db_not_connected_error.value)
range_error: Final = _aggregated_date_range_error(start_date, end_date)
range_error: Final = aggregated_date_range_error(start_date, end_date)
if range_error is not None or start_date is None or end_date is None:
raise _daily_activity_error(status_code=400, message=range_error or "Please provide start_date and end_date")
if not team_ids:
raise _daily_activity_error(status_code=400, message="Please provide team_ids")
scope: Final = await _resolve_team_daily_activity_scope(
scope: Final = await resolve_team_daily_activity_scope(
team_ids=team_ids,
exclude_team_ids=None,
api_key=None,

View file

@ -604,6 +604,7 @@ from litellm.proxy.management_endpoints.cost_tracking_settings import (
from litellm.proxy.management_endpoints.customer_endpoints import (
router as customer_router,
)
from litellm.proxy.management_endpoints.daily_activity_routes import router as daily_activity_router
from litellm.proxy.management_endpoints.fallback_management_endpoints import (
router as fallback_management_router,
)
@ -19951,6 +19952,7 @@ app.include_router(pass_through_router)
app.include_router(health_router)
app.include_router(key_management_router)
app.include_router(internal_user_router)
app.include_router(daily_activity_router)
app.include_router(password_management_router)
app.include_router(session_management_router)
app.include_router(team_router)

View file

@ -777,6 +777,7 @@ ANTHROPIC_EFFORT_BETA_HEADER: Final = "effort-2025-11-24"
ANTHROPIC_MID_CONVERSATION_OUTPUT_CONFIG_BETA_HEADER: Final = "mid-conversation-output-config-2026-07-01"
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER: Final = "thinking-display-updates-2026-08-18"
ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER: Final = "mid-conversation-tool-changes-2026-07-01"
ANTHROPIC_FINE_GRAINED_TOOL_STREAMING_BETA_HEADER: Final = "fine-grained-tool-streaming-2025-05-14"

View file

@ -126,6 +126,51 @@ class SpendAnalyticsPaginatedResponse(BaseModel):
metadata: DailySpendMetadata = Field(default_factory=DailySpendMetadata)
class KeyActivityRow(BaseModel):
api_key: str
metrics: SpendMetrics
metadata: KeyMetadata
class KeySpendMetrics(BaseModel):
spend: float = 0.0
prompt_tokens: int = 0
completion_tokens: int = 0
total_tokens: int = 0
api_requests: int = 0
successful_requests: int = 0
failed_requests: int = 0
cache_read_input_tokens: int = 0
cache_creation_input_tokens: int = 0
class KeySpendActivityRow(BaseModel):
api_key: str
metrics: KeySpendMetrics
metadata: KeyMetadata
class DailyActivityKeySearchResponse(BaseModel):
api_keys: list[KeyActivityRow]
class DailyActivityKeyPageResponse(BaseModel):
api_keys: list[KeySpendActivityRow]
total_api_keys: int
offset: int
limit: int
class ModelTopKeysResponse(BaseModel):
model: str
by_model_group: bool
api_keys: list[KeySpendActivityRow]
class CacheLeakageKeysResponse(BaseModel):
api_keys: list[KeySpendActivityRow]
class LiteLLM_DailyUserSpend(BaseModel):
id: str
user_id: str

View file

@ -12,14 +12,34 @@
# Read-only analytics and spend reporting; observability, not Terraform-managed state
GET /agent/daily/activity
GET /agent/daily/activity/aggregated
GET /agent/daily/activity/aggregated/keys
GET /agent/daily/activity/aggregated/model_top_keys
GET /agent/daily/activity/aggregated/search
GET /agent/daily/activity/export
GET /customer/daily/activity
GET /customer/daily/activity/aggregated
GET /customer/daily/activity/aggregated/keys
GET /customer/daily/activity/aggregated/model_top_keys
GET /customer/daily/activity/aggregated/search
GET /customer/daily/activity/export
GET /guardrails/usage/detail/{guardrail_id}
GET /guardrails/usage/logs
GET /guardrails/usage/overview
GET /key/spend/report
GET /organization/daily/activity
GET /organization/daily/activity/aggregated
GET /organization/daily/activity/aggregated/keys
GET /organization/daily/activity/aggregated/model_top_keys
GET /organization/daily/activity/aggregated/search
GET /organization/daily/activity/export
GET /organization/spend/report
GET /tag/daily/activity
GET /tag/daily/activity/aggregated
GET /tag/daily/activity/aggregated/keys
GET /tag/daily/activity/aggregated/model_top_keys
GET /tag/daily/activity/aggregated/search
GET /tag/daily/activity/export
GET /tag/dau
GET /tag/distinct
GET /tag/mau
@ -28,10 +48,19 @@ GET /tag/user-agent/per-user-analytics
GET /tag/wau
GET /team/daily/activity
GET /team/daily/activity/aggregated
GET /team/daily/activity/aggregated/keys
GET /team/daily/activity/aggregated/model_top_keys
GET /team/daily/activity/aggregated/search
GET /team/daily/activity/export
GET /team/spend/by_user
GET /team/spend/report
GET /user/daily/activity
GET /user/daily/activity/aggregated
GET /user/daily/activity/aggregated/keys
GET /user/daily/activity/aggregated/cache_leakage_keys
GET /user/daily/activity/aggregated/model_top_keys
GET /user/daily/activity/aggregated/search
GET /user/daily/activity/export
GET /user/spend/report
# Admin UI helper endpoints; serve UI forms and caller-scoped views, not desired state

View file

@ -11,7 +11,6 @@ litellm/proxy/_experimental/mcp_server/oauth2_flow_backfill.py backfill_null_oau
litellm/proxy/_experimental/mcp_server/oauth2_flow_backfill.py backfill_null_oauth2_flows prisma server_id.in `server_ids` 0
litellm/proxy/_experimental/mcp_server/toolset_db.py list_mcp_toolsets prisma toolset_id.in `toolset_ids` 0
litellm/proxy/agent_endpoints/endpoints.py _attach_keys_to_agents prisma agent_id.in `agent_ids` 0
litellm/proxy/agent_endpoints/endpoints.py get_agent_daily_activity prisma agent_id.in `list(agent_ids_list)` 0
litellm/proxy/agent_endpoints/endpoints.py get_agents prisma agent_id.in `agent_ids` 0
litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_skill_access.py SkillVisibility.where prisma name.in `sorted(self.granted)` 0
litellm/proxy/auth/auth_checks.py _fetch_uncached_model_access_group_budgets prisma access_group_name.in `list(uncached_groups)` 0
@ -43,7 +42,6 @@ litellm/proxy/management_endpoints/common_utils.py _team_admin_can_invite_user p
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
litellm/proxy/management_endpoints/customer_endpoints.py delete_end_user prisma user_id.in `data.user_ids` 1
litellm/proxy/management_endpoints/customer_endpoints.py get_customer_daily_activity prisma user_id.in `list(end_user_ids_list)` 0
litellm/proxy/management_endpoints/internal_user_endpoints.py _check_user_info_v2_access prisma team_id.in `caller_user.teams` 0
litellm/proxy/management_endpoints/internal_user_endpoints.py _resolve_user_email_metadata prisma user_id.in `list(user_ids)` 0
litellm/proxy/management_endpoints/internal_user_endpoints.py delete_user prisma created_by.in `data.user_ids` 0
@ -73,7 +71,6 @@ litellm/proxy/management_endpoints/mcp_management_endpoints.py fetch_all_mcp_ser
litellm/proxy/management_endpoints/model_access_group_management_endpoints.py update_deployments_with_access_group prisma model_name.in `model_names` 0
litellm/proxy/management_endpoints/model_management_endpoints.py delete_team_models prisma model_id.in `model_ids` 0
litellm/proxy/management_endpoints/organization_endpoints.py deprecated_info_organization prisma organization_id.in `data.organizations` 0
litellm/proxy/management_endpoints/organization_endpoints.py get_organization_daily_activity prisma organization_id.in `list(org_ids_list)` 0
litellm/proxy/management_endpoints/organization_endpoints.py list_organization prisma organization_id.in `membership_org_ids` 0
litellm/proxy/management_endpoints/router_weights.py validate_router_settings_weights prisma model_id.in `list(deployment_ids)` 0
litellm/proxy/management_endpoints/session_endpoints.py revoke_ui_session_keys prisma token.in `revoked_tokens` 0
@ -91,7 +88,7 @@ litellm/proxy/management_endpoints/team_endpoints.py _build_team_list_where_cond
litellm/proxy/management_endpoints/team_endpoints.py _get_keys_count_by_team prisma team_id.in `page_team_ids` 0
litellm/proxy/management_endpoints/team_endpoints.py _hydrate_member_user_details prisma user_id.in `sorted(user_ids)` 0
litellm/proxy/management_endpoints/team_endpoints.py _resolve_existing_member_user_ids prisma user_id.in `sorted(requested_user_ids)` 0
litellm/proxy/management_endpoints/team_endpoints.py _resolve_team_daily_activity_scope prisma team_id.in `list(team_ids_list)` 0
litellm/proxy/management_endpoints/team_endpoints.py resolve_team_daily_activity_scope prisma team_id.in `list(team_ids_list)` 0
litellm/proxy/management_endpoints/team_endpoints.py _sweep_deleted_team_references prisma team_id.in `tuple(team_ids)` 0
litellm/proxy/management_endpoints/team_endpoints.py _sweep_deleted_team_references_tx prisma team_id.in `tuple(team_ids)` 0
litellm/proxy/management_endpoints/team_endpoints.py _team_member_delete prisma user_id.in `sorted(addressed_user_ids)` 0

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,514 @@
import csv
import hashlib
import io
import uuid
from datetime import datetime, timedelta, timezone
from itertools import chain
from pathlib import Path
from typing import Final
import httpx
import pytest
from fastapi import FastAPI
from integration._support.client import JSON_OBJECT, Gateway, eventually, object_value, string_value
from integration._support.database import read_rows
from integration._support.process import owned_proxy
from integration._support.upstream import JsonResponse, delete_scenario, register_scenario
from integration.spend.test_daily_activity_repository import _daily_activity_database, _PrismaDatabase, _repository
from litellm import constants
from litellm.proxy import proxy_server
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.daily_activity_routes import (
get_daily_activity_prisma_client,
get_daily_activity_repository,
)
from litellm.proxy.management_endpoints.daily_activity_routes import (
router as daily_activity_router,
)
from litellm.proxy.management_endpoints.internal_user_endpoints import router as internal_user_router
from litellm.proxy.management_endpoints.team_endpoints import router as team_router
from litellm.types.proxy.management_endpoints.common_daily_activity import DailyActivityKeyPageResponse
def _delete_organization(gateway: Gateway, organization_id: str) -> None:
response: Final = gateway.request("DELETE", "/organization/delete", {"organization_ids": [organization_id]})
assert response.status_code == 200, response.text
def _delete_tag(gateway: Gateway, tag: str) -> None:
response: Final = gateway.request("POST", "/tag/delete", {"name": tag})
assert response.status_code == 200, response.text
def _delete_end_user(gateway: Gateway, end_user_id: str) -> None:
response: Final = gateway.request("POST", "/end_user/delete", {"user_ids": [end_user_id]})
assert response.status_code == 200, response.text
def _delete_agent(gateway: Gateway, agent_id: str) -> None:
response: Final = gateway.request("DELETE", f"/v1/agents/{agent_id}")
assert response.status_code == 200, response.text
def _daily_activity_request(
gateway: Gateway,
*,
model: str,
key: str,
end_user_id: str,
tag: str,
request_number: int,
) -> None:
response: Final = gateway.request(
"POST",
"/v1/chat/completions",
JSON_OBJECT.validate_python(
{
"model": model,
"messages": [{"role": "user", "content": f"daily activity {request_number}"}],
"metadata": {"tags": [tag]},
"user": end_user_id,
}
),
key=key,
)
assert response.status_code == 200, response.text
def _aggregate_result_api_keys(result: object) -> tuple[str, ...]:
result_body: Final = object_value(result)
breakdown: Final = object_value(result_body["breakdown"])
api_keys: Final = object_value(breakdown["api_keys"])
return tuple(api_keys)
def _aggregate_top_keys(results: object) -> frozenset[str]:
assert isinstance(results, list)
api_keys_by_result: Final = tuple(_aggregate_result_api_keys(result) for result in results)
return frozenset(chain.from_iterable(api_keys_by_result))
def _assert_entity_activity_routes(
gateway: Gateway,
*,
prefix: str,
entity_param: str,
entity_id: str,
table: str,
entity_column: str,
date_params: dict[str, str],
target_digest: str,
model: str,
) -> None:
params: Final = {**date_params, entity_param: entity_id}
persisted_rows: Final = eventually(
lambda: read_rows(
f'SELECT api_key FROM "{table}" WHERE "{entity_column}"=%s AND date BETWEEN %s AND %s',
(entity_id, date_params["start_date"], date_params["end_date"]),
),
lambda rows: len(rows) == 6,
seconds=70,
)
aggregated: Final = gateway.request(
"GET",
f"{prefix}/daily/activity/aggregated",
params={**params, "api_key_limit": "3"},
)
assert aggregated.status_code == 200, aggregated.text
aggregate_body: Final = object_value(aggregated.json())
metadata: Final = object_value(aggregate_body["metadata"])
total_api_keys: Final = metadata["total_api_keys"]
api_key_limit: Final = metadata["api_key_limit"]
assert isinstance(total_api_keys, int) and total_api_keys == 6, aggregated.text
assert isinstance(api_key_limit, int) and api_key_limit == 3, aggregated.text
assert total_api_keys > api_key_limit, aggregated.text
assert metadata["total_api_requests"] == 8, aggregated.text
top_api_keys: Final = _aggregate_top_keys(aggregate_body["results"])
assert target_digest not in top_api_keys, aggregated.text
ranked_rows: Final = read_rows(
f'SELECT api_key FROM "{table}" WHERE "{entity_column}"=%s AND date BETWEEN %s AND %s '
"AND api_key <> %s GROUP BY api_key ORDER BY SUM(spend::numeric) DESC, api_key",
(
entity_id,
date_params["start_date"],
date_params["end_date"],
constants.PTU_SENTINEL_API_KEY,
),
)
ranked_keys: Final = tuple(string_value(row["api_key"]) for row in ranked_rows)
page_responses: Final = tuple(
gateway.request(
"GET",
f"{prefix}/daily/activity/aggregated/keys",
params={**params, "offset": str(offset), "limit": "2"},
)
for offset in range(0, len(ranked_keys), 2)
)
assert all(response.status_code == 200 for response in page_responses), tuple(
response.text for response in page_responses
)
page_bodies: Final = tuple(
DailyActivityKeyPageResponse.model_validate_json(response.content) for response in page_responses
)
page_api_keys: Final = tuple(tuple(row.api_key for row in body.api_keys) for body in page_bodies)
paged_keys: Final = tuple(chain.from_iterable(page_api_keys))
assert tuple(body.total_api_keys for body in page_bodies) == (6,) * len(page_bodies)
assert paged_keys == ranked_keys
assert len(paged_keys) == len(frozenset(paged_keys))
assert frozenset(paged_keys[:3]) == top_api_keys, aggregated.text
key_details: Final = gateway.request(
"GET",
f"{prefix}/daily/activity/aggregated",
params={**params, "api_key": target_digest},
)
assert key_details.status_code == 200, key_details.text
key_details_body: Final = JSON_OBJECT.validate_json(key_details.content)
assert object_value(key_details_body["metadata"])["total_api_keys"] == 1, key_details.text
assert _aggregate_top_keys(key_details_body["results"]) == frozenset((target_digest,)), key_details.text
searched: Final = gateway.request(
"GET",
f"{prefix}/daily/activity/aggregated/search",
params={**params, "search": target_digest},
)
assert searched.status_code == 200, searched.text
search_body: Final = object_value(searched.json())
search_rows: Final = search_body["api_keys"]
assert isinstance(search_rows, list) and len(search_rows) == 1, searched.text
assert object_value(search_rows[0])["api_key"] == target_digest, searched.text
top_keys: Final = gateway.request(
"GET",
f"{prefix}/daily/activity/aggregated/model_top_keys",
params={**params, "model_group": model},
)
assert top_keys.status_code == 200, top_keys.text
top_body: Final = object_value(top_keys.json())
top_rows: Final = top_body["api_keys"]
assert isinstance(top_rows, list) and len(top_rows) == 5, top_keys.text
top_spends: Final = tuple(object_value(object_value(row)["metrics"])["spend"] for row in top_rows[:2])
assert top_spends == (
pytest.approx(0.12),
pytest.approx(0.12),
), top_keys.text
exported: Final = gateway.request(
"GET",
f"{prefix}/daily/activity/export",
params={**params, "export_type": "daily_with_keys"},
)
assert exported.status_code == 200, exported.text
export_rows: Final = tuple(csv.reader(io.StringIO(exported.text)))
assert len(export_rows) == len(persisted_rows) + 1, exported.text
@pytest.mark.timeout(90)
def test_daily_activity_routes_cover_all_entities_and_bounded_key_search(gateway: Gateway, tmp_path: Path) -> None:
with owned_proxy(gateway, tmp_path, {}) as proxy:
_assert_daily_activity_routes(proxy)
def _assert_daily_activity_routes(gateway: Gateway) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002)
target_scenario_id: Final = f"usage-cache-{uuid.uuid4().hex}"
target_response: Final = JsonResponse(
content_type="application/json",
body=JSON_OBJECT.validate_python(
{
"id": "$UNIQUE_ID",
"object": "chat.completion",
"created": 1_700_000_000,
"model": "gpt-4o-mini",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "cached response"},
"finish_reason": "stop",
}
],
"usage": {
"prompt_tokens": 40,
"completion_tokens": 20,
"total_tokens": 60,
"prompt_tokens_details": {"cached_tokens": 20},
},
}
),
)
target_upstream: Final = register_scenario(target_scenario_id, target_response)
scenario.cleanups.callback(delete_scenario, target_upstream)
cache_model: Final = scenario.model(
api_base=target_upstream.api_base(),
api_key=target_scenario_id,
input_cost_per_token=0.0,
output_cost_per_token=0.0,
)
organization: Final = gateway.post(
"/organization/new",
{"organization_alias": f"integration-{uuid.uuid4().hex}"},
)
organization_id: Final = string_value(organization["organization_id"])
scenario.cleanups.callback(_delete_organization, gateway, organization_id)
team_id: Final = scenario.team(organization_id=organization_id)
user_id: Final = scenario.user()
tag: Final = f"integration-{uuid.uuid4().hex}"
gateway.post("/tag/new", {"name": tag})
scenario.cleanups.callback(_delete_tag, gateway, tag)
end_user_id: Final = f"integration-{uuid.uuid4().hex}"
gateway.post("/end_user/new", {"user_id": end_user_id})
scenario.cleanups.callback(_delete_end_user, gateway, end_user_id)
agent_response: Final = gateway.request(
"POST",
"/v1/agents",
{
"agent_name": f"integration-{uuid.uuid4().hex}",
"agent_card_params": {
"protocolVersion": "0.3",
"name": "integration",
"description": "integration agent",
"url": "http://127.0.0.1:1/agent",
"version": "1",
"capabilities": {},
"defaultInputModes": ["text"],
"defaultOutputModes": ["text"],
"skills": [],
},
},
)
assert agent_response.status_code == 200, agent_response.text
agent_id: Final = string_value(object_value(agent_response.json())["agent_id"])
scenario.cleanups.callback(_delete_agent, gateway, agent_id)
keys: Final = tuple(
scenario.key(
models=[model, cache_model],
team_id=team_id,
user_id=user_id,
organization_id=organization_id,
agent_id=agent_id,
)
for _ in range(5)
)
target_key: Final = scenario.key(
models=[model, cache_model],
team_id=team_id,
user_id=user_id,
organization_id=organization_id,
agent_id=agent_id,
)
for request_number, key in enumerate(keys):
_daily_activity_request(
gateway,
model=model,
key=key,
end_user_id=end_user_id,
tag=tag,
request_number=request_number,
)
for request_number, key in enumerate(keys[:2]):
_daily_activity_request(
gateway,
model=model,
key=key,
end_user_id=end_user_id,
tag=tag,
request_number=100 + request_number,
)
target_request: Final = gateway.request(
"POST",
"/v1/chat/completions",
JSON_OBJECT.validate_python(
{
"model": cache_model,
"messages": [{"role": "user", "content": "cached activity response"}],
"metadata": {"tags": [tag]},
"user": end_user_id,
}
),
key=target_key,
)
assert target_request.status_code == 200, target_request.text
today: Final = datetime.now(timezone.utc).date()
start_date: Final = (today - timedelta(days=1)).isoformat()
end_date: Final = (today + timedelta(days=1)).isoformat()
date_params: Final = {"start_date": start_date, "end_date": end_date, "timezone": "0"}
route_cases: Final = (
("/user", "user_id", user_id, "LiteLLM_DailyUserSpend", "user_id"),
("/team", "team_ids", team_id, "LiteLLM_DailyTeamSpend", "team_id"),
("/tag", "tags", tag, "LiteLLM_DailyTagSpend", "tag"),
(
"/organization",
"organization_ids",
organization_id,
"LiteLLM_DailyOrganizationSpend",
"organization_id",
),
("/customer", "end_user_ids", end_user_id, "LiteLLM_DailyEndUserSpend", "end_user_id"),
("/agent", "agent_ids", agent_id, "LiteLLM_DailyAgentSpend", "agent_id"),
)
target_digest: Final = hashlib.sha256(target_key.encode()).hexdigest()
for prefix, entity_param, entity_id, table, entity_column in route_cases:
_assert_entity_activity_routes(
gateway,
prefix=prefix,
entity_param=entity_param,
entity_id=entity_id,
table=table,
entity_column=entity_column,
date_params=date_params,
target_digest=target_digest,
model=model,
)
user_cache_keys: Final = gateway.request(
"GET",
"/user/daily/activity/aggregated/cache_leakage_keys",
params={**date_params, "user_id": user_id},
)
assert user_cache_keys.status_code == 200, user_cache_keys.text
cache_rows: Final = object_value(user_cache_keys.json())["api_keys"]
assert isinstance(cache_rows, list) and cache_rows, user_cache_keys.text
cache_api_keys: Final = tuple(string_value(object_value(row)["api_key"]) for row in cache_rows)
assert target_digest in cache_api_keys, user_cache_keys.text
async def _assert_route_matches_golden(client: httpx.AsyncClient, route: str, golden_name: str) -> None:
response: Final = await client.get(
route,
params={"start_date": "2026-06-01", "end_date": "2026-06-01"},
)
assert response.status_code == 200, response.text
golden: Final = (Path(__file__).parent / "golden" / golden_name).read_text()
expected: Final = JSON_OBJECT.validate_json(golden)
actual: Final = object_value(response.json())
assert actual == expected, route
@pytest.mark.asyncio
async def test_existing_activity_routes_match_base_branch_goldens(monkeypatch: pytest.MonkeyPatch) -> None:
async with _daily_activity_database() as database:
repository: Final = _repository(database)
app: Final = FastAPI()
app.include_router(internal_user_router)
app.include_router(team_router)
app.include_router(daily_activity_router)
monkeypatch.setattr(proxy_server, "prisma_client", _PrismaDatabase(database))
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="integration-admin", user_role=LitellmUserRoles.PROXY_ADMIN
)
app.dependency_overrides[get_daily_activity_prisma_client] = lambda: _PrismaDatabase(database)
app.dependency_overrides[get_daily_activity_repository] = lambda: repository
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") as client:
route_goldens: Final = (
("/user/daily/activity", "daily_activity_user_paginated.json"),
("/user/daily/activity/aggregated", "daily_activity_user_aggregated.json"),
("/team/daily/activity", "daily_activity_team_paginated.json"),
("/team/daily/activity/aggregated", "daily_activity_team_aggregated.json"),
)
for route, golden_name in route_goldens:
await _assert_route_matches_golden(client, route, golden_name)
@pytest.mark.asyncio
async def test_user_key_pages_and_details_respect_caller_scope() -> None:
async with _daily_activity_database() as database:
await database.query_raw(
'INSERT INTO "LiteLLM_UserTable" (user_id, user_email, models) VALUES ($1, $2, $3)',
"user-2",
"other@example.test",
[],
)
await database.query_raw(
"""
INSERT INTO "LiteLLM_VerificationToken"
(token, key_alias, team_id, user_id, metadata, models)
VALUES ($1, $2, $3, $4, $5::jsonb, $6)
""",
"key-other-user",
"Other user key",
None,
"user-2",
"{}",
[],
)
await database.query_raw(
"""
INSERT INTO "LiteLLM_DailyUserSpend"
(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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18::timestamp)
""",
"other-user-row",
"user-2",
"2026-06-01",
"key-other-user",
"model",
"",
"provider-a",
None,
"/v1/chat/completions",
1,
1,
0,
0,
50.0,
1,
1,
0,
"2026-06-01 12:00:00",
)
repository: Final = _repository(database)
app: Final = FastAPI()
app.include_router(daily_activity_router)
app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(
user_id="user-1",
user_role=LitellmUserRoles.INTERNAL_USER,
)
app.dependency_overrides[get_daily_activity_prisma_client] = lambda: _PrismaDatabase(database)
app.dependency_overrides[get_daily_activity_repository] = lambda: repository
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app),
base_url="http://testserver",
) as client:
params: Final = {"start_date": "2026-06-01", "end_date": "2026-06-01"}
page: Final = await client.get(
"/user/daily/activity/aggregated/keys",
params={**params, "user_id": "user-1", "limit": 100},
)
assert page.status_code == 200, page.text
page_body: Final = DailyActivityKeyPageResponse.model_validate_json(page.content)
page_keys: Final = frozenset(row.api_key for row in page_body.api_keys)
assert page_body.total_api_keys == len(page_keys) == 5
assert "key-other-user" not in page_keys
denied: Final = await client.get(
"/user/daily/activity/aggregated/keys",
params={**params, "user_id": "user-2"},
)
assert denied.status_code == 403, denied.text
own_details: Final = await client.get(
"/user/daily/activity/aggregated",
params={**params, "user_id": "user-1", "api_key": "key-a"},
)
assert own_details.status_code == 200, own_details.text
own_body: Final = JSON_OBJECT.validate_json(own_details.content)
assert object_value(own_body["metadata"])["total_api_keys"] == 1
assert _aggregate_top_keys(own_body["results"]) == frozenset(("key-a",))
other_details: Final = await client.get(
"/user/daily/activity/aggregated",
params={**params, "user_id": "user-1", "api_key": "key-other-user"},
)
assert other_details.status_code == 200, other_details.text
other_body: Final = JSON_OBJECT.validate_json(other_details.content)
assert object_value(other_body["metadata"])["total_api_keys"] == 0
assert _aggregate_top_keys(other_body["results"]) == frozenset()

View file

@ -150,3 +150,33 @@ def test_native_messages_thinking_display_updates_beta(display: str | None, expl
)
assert headers.get("anthropic-beta", "").split(",").count(beta) == int(display == "updates" or explicit_beta)
@pytest.mark.parametrize("action", (None, "tool_addition", "tool_removal"))
@pytest.mark.parametrize("explicit_beta", (False, True))
def test_native_messages_tool_changes_beta(action: str | None, explicit_beta: bool) -> None:
from typing import Final
from litellm.types.llms.anthropic import (
ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER,
ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER,
)
beta: Final = ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER
content: Final = (
[{"type": action, "tool": {"type": "tool_reference", "name": "mcp__test__ping"}}]
if action
else "Answer briefly"
)
headers, _ = AnthropicMessagesConfig().validate_anthropic_messages_environment(
headers={"anthropic-beta": beta} if explicit_beta else {},
model="claude-fable-5-1",
messages=["not a message dict", {"role": "user", "content": "Hello"}, {"role": "system", "content": content}],
optional_params={"thinking": {"type": "adaptive", "display": "updates"}},
litellm_params={},
api_key="sk-ant-test",
)
assert headers.get("anthropic-beta", "").split(",").count(beta) == int(action is not None or explicit_beta)
assert ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER in headers.get("anthropic-beta", "").split(",")

View file

@ -2450,3 +2450,57 @@ def test_shared_legacy_thinking_translation_preserves_supported_display(
)
assert optional_params["thinking"] == expected_thinking
@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config")
@pytest.mark.parametrize("action", (None, "tool_addition", "tool_removal"))
@pytest.mark.parametrize("explicit_beta", (False, True))
def test_validate_environment_adds_tool_changes_beta(action: str | None, explicit_beta: bool) -> None:
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.types.llms.anthropic import ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER
beta: Final = ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER
content: Final = (
[{"type": action, "tool": {"type": "tool_reference", "name": "mcp__test__ping"}}]
if action
else "Answer briefly"
)
headers: Final = AnthropicModelInfo().validate_environment(
headers={"anthropic-beta": beta} if explicit_beta else {},
model="claude-fable-5-1",
messages=[{"role": "user", "content": "Hello"}, {"role": "system", "content": content}],
optional_params={},
litellm_params={},
api_key=FAKE_REGULAR_KEY,
)
assert headers.get("anthropic-beta", "").split(",").count(beta) == int(action is not None or explicit_beta)
assert headers["x-api-key"] == FAKE_REGULAR_KEY
@pytest.mark.parametrize(
("role", "content"),
(
("user", [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "ping"}}]),
("assistant", [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "ping"}}]),
("system", "tool_addition"),
("system", None),
("system", ["tool_addition"]),
("system", [{"type": "tool_reference", "name": "ping"}]),
("system", [{"type": "tool_addition", "tool": {"type": "tool_definition", "definition": {"name": "ping"}}}]),
),
)
def test_tool_changes_beta_requires_system_tool_reference(role: str, content: object) -> None:
from litellm.llms.anthropic.common_utils import AnthropicModelInfo
from litellm.types.llms.anthropic import ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER
headers: Final = AnthropicModelInfo().validate_environment(
headers={},
model="claude-fable-5-1",
messages=[{"role": role, "content": content}],
optional_params={},
litellm_params={},
api_key=FAKE_REGULAR_KEY,
)
assert ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER not in headers.get("anthropic-beta", "").split(",")

View file

@ -3527,3 +3527,54 @@ def test_bedrock_clear_thinking_preserves_display_updates() -> None:
assert result.get("thinking") == {"type": "adaptive", "display": "updates"}
assert ANTHROPIC_THINKING_DISPLAY_UPDATES_BETA_HEADER in result.get("anthropic_beta", [])
@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config")
@pytest.mark.parametrize("action", (None, "tool_addition", "tool_removal"))
@pytest.mark.parametrize("explicit_beta", (False, True))
def test_bedrock_messages_tool_changes_beta(action: str | None, explicit_beta: bool) -> None:
from litellm.types.llms.anthropic import ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER
from litellm.types.router import GenericLiteLLMParams
beta: Final = ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER
content: Final = (
[{"type": action, "tool": {"type": "tool_reference", "name": "mcp__test__ping"}}]
if action
else "Answer briefly"
)
messages: Final = [{"role": "user", "content": "Hello"}, {"role": "system", "content": content}]
result: Final = AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request(
model="global.anthropic.claude-fable-5-1",
messages=messages,
anthropic_messages_optional_request_params={"max_tokens": 512},
litellm_params=GenericLiteLLMParams(),
headers={"anthropic-beta": beta} if explicit_beta else {},
)
assert result.get("anthropic_beta", []).count(beta) == int(action is not None or explicit_beta)
assert result["messages"] == messages
@pytest.mark.usefixtures("local_model_cost_map", "local_beta_headers_config")
@pytest.mark.parametrize("explicit_beta", (False, True))
def test_bedrock_removed_tool_change_does_not_add_beta(explicit_beta: bool) -> None:
from litellm.types.llms.anthropic import ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER
from litellm.types.router import GenericLiteLLMParams
beta: Final = ANTHROPIC_MID_CONVERSATION_TOOL_CHANGES_BETA_HEADER
result: Final = AmazonAnthropicClaudeMessagesConfig().transform_anthropic_messages_request(
model="global.anthropic.claude-fable-5-1",
messages=[
{
"role": "system",
"content": [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "ping"}}],
},
{"role": "user", "content": "Reply with OK"},
],
anthropic_messages_optional_request_params={"max_tokens": 512},
litellm_params=GenericLiteLLMParams(),
headers={"anthropic-beta": beta} if explicit_beta else {},
)
assert result["messages"] == [{"role": "user", "content": "Reply with OK"}]
assert result.get("anthropic_beta", []).count(beta) == int(explicit_beta)

View file

@ -16,6 +16,93 @@ from litellm.proxy._types import (
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
from litellm.proxy.auth.route_checks import RouteChecks
DAILY_ACTIVITY_ROUTE_PAIRS: Final[tuple[tuple[str, str], ...]] = (
("/user/daily/activity", "/user/daily/activity/aggregated"),
("/user/daily/activity", "/user/daily/activity/aggregated/keys"),
("/user/daily/activity", "/user/daily/activity/aggregated/search"),
("/user/daily/activity", "/user/daily/activity/aggregated/model_top_keys"),
("/user/daily/activity", "/user/daily/activity/export"),
("/user/daily/activity", "/user/daily/activity/aggregated/cache_leakage_keys"),
("/team/daily/activity", "/team/daily/activity/aggregated"),
("/team/daily/activity", "/team/daily/activity/aggregated/keys"),
("/team/daily/activity", "/team/daily/activity/aggregated/search"),
("/team/daily/activity", "/team/daily/activity/aggregated/model_top_keys"),
("/team/daily/activity", "/team/daily/activity/export"),
("/tag/daily/activity", "/tag/daily/activity/aggregated"),
("/tag/daily/activity", "/tag/daily/activity/aggregated/keys"),
("/tag/daily/activity", "/tag/daily/activity/aggregated/search"),
("/tag/daily/activity", "/tag/daily/activity/aggregated/model_top_keys"),
("/tag/daily/activity", "/tag/daily/activity/export"),
("/organization/daily/activity", "/organization/daily/activity/aggregated"),
("/organization/daily/activity", "/organization/daily/activity/aggregated/keys"),
("/organization/daily/activity", "/organization/daily/activity/aggregated/search"),
("/organization/daily/activity", "/organization/daily/activity/aggregated/model_top_keys"),
("/organization/daily/activity", "/organization/daily/activity/export"),
("/customer/daily/activity", "/customer/daily/activity/aggregated"),
("/customer/daily/activity", "/customer/daily/activity/aggregated/keys"),
("/customer/daily/activity", "/customer/daily/activity/aggregated/search"),
("/customer/daily/activity", "/customer/daily/activity/aggregated/model_top_keys"),
("/customer/daily/activity", "/customer/daily/activity/export"),
("/customer/daily/activity", "/end_user/daily/activity/aggregated"),
("/customer/daily/activity", "/end_user/daily/activity/aggregated/keys"),
("/customer/daily/activity", "/end_user/daily/activity/aggregated/search"),
("/customer/daily/activity", "/end_user/daily/activity/aggregated/model_top_keys"),
("/customer/daily/activity", "/end_user/daily/activity/export"),
("/agent/daily/activity", "/agent/daily/activity/aggregated"),
("/agent/daily/activity", "/agent/daily/activity/aggregated/keys"),
("/agent/daily/activity", "/agent/daily/activity/aggregated/search"),
("/agent/daily/activity", "/agent/daily/activity/aggregated/model_top_keys"),
("/agent/daily/activity", "/agent/daily/activity/export"),
)
DAILY_ACTIVITY_ROLES: Final[tuple[LitellmUserRoles, ...]] = (
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
LitellmUserRoles.ORG_ADMIN,
LitellmUserRoles.TEAM,
LitellmUserRoles.CUSTOMER,
)
def _daily_activity_route_outcome(route: str, user_role: LitellmUserRoles) -> str:
if user_role == LitellmUserRoles.PROXY_ADMIN:
return "allowed"
user_obj = LiteLLM_UserTable(
user_id="test_user",
user_email="test@example.com",
user_role=user_role.value,
)
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=user_role)
request = MagicMock(spec=Request)
request.method = "GET"
request.query_params = {}
try:
RouteChecks.non_proxy_admin_allowed_routes_check(
user_obj=user_obj,
_user_role=user_role.value,
route=route,
request=request,
valid_token=valid_token,
request_data={},
)
except HTTPException as exc:
return f"denied:{exc.status_code}"
except Exception as exc:
return f"denied:{type(exc).__name__}"
return "allowed"
@pytest.mark.parametrize(("existing_path", "new_path"), DAILY_ACTIVITY_ROUTE_PAIRS)
@pytest.mark.parametrize("user_role", DAILY_ACTIVITY_ROLES)
def test_daily_activity_routes_preserve_route_access_outcomes(
existing_path: str, new_path: str, user_role: LitellmUserRoles
) -> None:
assert _daily_activity_route_outcome(new_path, user_role) == _daily_activity_route_outcome(
existing_path, user_role
)
def test_non_admin_config_update_route_rejected():
"""Test that non-admin users are rejected when trying to call /config/update"""
@ -2219,7 +2306,7 @@ def test_internal_user_can_access_logs_drawer_detail_route(user_role):
request_data={},
)
except Exception as e:
pytest.fail(f"{user_role.value} should be able to access {route}. Got error: {str(e)}")
pytest.fail(f"{user_role.value} should be able to access {route}. Got error: {e!s}")
@pytest.mark.parametrize(

View file

@ -387,3 +387,59 @@ async def test_agent_activity_non_admin_no_access_returns_empty_page():
assert result.results == []
fake_get_daily.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("owned_tokens", "requested_api_key"),
[
([], None),
(["alice-key-1"], "bob-key-1"),
],
)
async def test_team_activity_member_without_matching_keys_queries_nothing(
owned_tokens: list[str], requested_api_key: str | None
) -> None:
"""A member without full team view whose key list is empty, or who asks for
a key they do not own, must reach the repository with an empty key filter,
never with no filter at all."""
from litellm.proxy.management_endpoints import common_daily_activity, team_endpoints
from litellm.repositories.daily_activity_sql import build_where_clause
from litellm.types.repositories.daily_activity import DailyRowsPage
user = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER.value)
prisma = MagicMock()
prisma.db.litellm_teamtable.find_many = AsyncMock(return_value=[_make_team("team-B", admin_user_ids=["bob"])])
prisma.db.litellm_verificationtoken.find_many = AsyncMock(
return_value=[MagicMock(token=token) for token in owned_tokens]
)
user_info = MagicMock()
user_info.teams = ["team-B"]
repository = MagicMock()
repository.daily_rows = AsyncMock(return_value=DailyRowsPage(total_count=0, rows=()))
with (
patch.object(team_endpoints, "prisma_client", prisma, create=True),
patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new=AsyncMock(return_value=user_info),
),
patch.object(common_daily_activity, "daily_activity_repository", return_value=repository),
patch("litellm.proxy.proxy_server.prisma_client", prisma),
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()),
patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()),
):
response = await team_endpoints.get_team_daily_activity(
team_ids="team-B",
start_date="2026-01-01",
end_date="2026-01-02",
api_key=requested_api_key,
user_api_key_dict=user,
)
scope = repository.daily_rows.await_args.args[0]
assert scope.api_keys == ()
sql, _params = build_where_clause(scope)
assert sql.endswith(" AND FALSE")
assert response.results == []
assert response.metadata.total_spend == 0

View file

@ -10,6 +10,7 @@ from fastapi import HTTPException
import litellm.proxy.management_endpoints.common_daily_activity as common_daily_activity_module
from litellm.constants import USAGE_TOP_API_KEYS_DEFAULT
from litellm.proxy.management_endpoints.common_daily_activity import (
InvalidDateRange,
_is_user_agent_tag,
_ProxyDailyActivityReads,
_record_to_spend_metrics,
@ -18,6 +19,7 @@ from litellm.proxy.management_endpoints.common_daily_activity import (
daily_activity_scope,
get_api_key_metadata,
get_daily_activity,
raise_public,
update_metrics,
)
from litellm.proxy.management_endpoints.common_daily_activity import (
@ -2332,17 +2334,17 @@ async def test_get_api_key_metadata_resolves_session_key_via_spend_log_window():
def test_spend_logs_window_pads_min_minus_one_day_and_max_plus_two_days():
from litellm.proxy.management_endpoints.common_daily_activity import _spend_logs_window
from litellm.proxy.management_endpoints.common_daily_activity import spend_logs_window
window = _spend_logs_window({"2026-09-08", "2026-09-05", "not-a-date"})
window = spend_logs_window({"2026-09-08", "2026-09-05", "not-a-date"})
assert window == (datetime(2026, 9, 4), datetime(2026, 9, 10))
def test_spend_logs_window_is_none_when_no_date_parses():
from litellm.proxy.management_endpoints.common_daily_activity import _spend_logs_window
from litellm.proxy.management_endpoints.common_daily_activity import spend_logs_window
assert _spend_logs_window({"garbage", ""}) is None
assert spend_logs_window({"garbage", ""}) is None
@pytest.mark.asyncio
@ -2447,3 +2449,10 @@ async def test_get_api_key_metadata_does_not_recover_daily_spend_owner_for_activ
assert active_metadata.get("user_email") == "active-owner@example.com"
assert active_metadata.get("key_exists") is True
recovery_query_raw.assert_not_awaited()
def test_raise_public_maps_invalid_date_range_to_400() -> None:
with pytest.raises(HTTPException) as excinfo:
raise_public(InvalidDateRange(reason="Date range must be at most 400 days"))
assert excinfo.value.status_code == 400
assert excinfo.value.detail == {"error": "Date range must be at most 400 days"}

File diff suppressed because it is too large Load diff

View file

@ -15,7 +15,6 @@ from fastapi.testclient import TestClient
from pytest_mock import MockerFixture
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.proxy._types import (
LiteLLM_UserTableFiltered,
LitellmUserRoles,
@ -26,12 +25,10 @@ from litellm.proxy._types import (
UserAPIKeyAuth,
)
from litellm.proxy.management_endpoints.internal_user_endpoints import (
LiteLLM_UserTableWithKeyCount,
_authorize_user_list_request,
_resolve_org_filter_for_user_search,
_resolve_user_email_metadata,
_update_internal_user_params,
get_user_key_counts,
get_users,
new_user,
ui_view_users,
@ -2480,185 +2477,6 @@ async def test_get_user_daily_activity_rejects_service_account_caller(monkeypatc
mock_get_daily.assert_not_called()
@pytest.mark.asyncio
async def test_get_user_daily_activity_aggregated_rejects_service_account_caller(
monkeypatch,
):
"""
Same security regression as
test_get_user_daily_activity_rejects_service_account_caller, on the
aggregated route. Same shape, raw-SQL builder, same fix.
"""
from unittest.mock import AsyncMock, MagicMock
from fastapi import HTTPException
from litellm.proxy.management_endpoints.internal_user_endpoints import (
get_user_daily_activity_aggregated,
)
mock_prisma_client = MagicMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
mock_get_daily_agg = AsyncMock()
monkeypatch.setattr(
"litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated",
mock_get_daily_agg,
)
service_account_key = UserAPIKeyAuth(
user_id=None,
user_role=LitellmUserRoles.INTERNAL_USER,
)
with pytest.raises(HTTPException) as exc_info:
await get_user_daily_activity_aggregated(
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
user_id=None,
timezone=None,
user_api_key_dict=service_account_key,
)
assert exc_info.value.status_code == 403
assert "Service-account keys" in str(exc_info.value.detail)
mock_get_daily_agg.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("include_current_utc_day", [False, True])
async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch, include_current_utc_day):
"""
Test that admin users can call the aggregated endpoint without a user_id
to get a global view. Also verifies that the correct arguments are forwarded
to the underlying get_daily_activity_aggregated helper.
"""
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy.management_endpoints.internal_user_endpoints import (
get_user_daily_activity_aggregated,
)
# Mock the prisma client
mock_prisma_client = MagicMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
# Mock the downstream helper so we don't need a real DB
mock_response = MagicMock()
mock_get_daily_agg = AsyncMock(return_value=mock_response)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated",
mock_get_daily_agg,
)
# Admin caller
admin_key_dict = UserAPIKeyAuth(
user_id="admin-user-001",
user_role=LitellmUserRoles.PROXY_ADMIN,
)
# Admin calls without user_id → global view (entity_id=None)
result = await get_user_daily_activity_aggregated(
start_date="2025-02-01",
end_date="2025-02-28",
model="gpt-4",
api_key=None,
user_id=None,
timezone=480,
include_current_utc_day=include_current_utc_day,
user_api_key_dict=admin_key_dict,
)
assert result is mock_response
# Verify the helper was called with the right parameters
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
async def test_get_user_daily_activity_aggregated_non_admin_cannot_view_other_users(
monkeypatch,
):
"""
Same scoping contract as
test_get_user_daily_activity_non_admin_cannot_view_other_users, on the
aggregated route. Non-admins reach this handler now that the route is in
self_managed_routes, so the 403-on-mismatch and default-to-self behaviour
has to hold here too: opening the route must not widen access.
"""
from unittest.mock import AsyncMock, MagicMock, patch
from fastapi import HTTPException
from litellm.proxy.management_endpoints.internal_user_endpoints import (
get_user_daily_activity_aggregated,
)
mock_prisma_client = MagicMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
non_admin_key_dict = UserAPIKeyAuth(
user_id="regular-user-123",
user_role=LitellmUserRoles.INTERNAL_USER,
)
# Case 1: Non-admin targets another user's data — 403, helper never reached
with patch(
"litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated",
new_callable=AsyncMock,
) as mock_get_daily_agg:
with pytest.raises(HTTPException) as exc_info:
await get_user_daily_activity_aggregated(
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
user_id="other-user-456",
timezone=None,
user_api_key_dict=non_admin_key_dict,
)
assert exc_info.value.status_code == 403
assert "Non-admin users can only view their own spend data" in str(exc_info.value.detail)
mock_get_daily_agg.assert_not_called()
# Case 2: Non-admin omits user_id — scoped to their own user_id, not global
mock_response = MagicMock()
with patch(
"litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_get_daily_agg:
result = await get_user_daily_activity_aggregated(
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
user_id=None,
timezone=None,
user_api_key_dict=non_admin_key_dict,
)
assert result is mock_response
mock_get_daily_agg.assert_called_once()
repository, scope = mock_get_daily_agg.call_args.args
assert repository is not None
assert scope.entity_ids == ("regular-user-123",)
@pytest.mark.asyncio
async def test_delete_user_cleans_up_created_by_invitation_links(mocker):
"""

View file

@ -1292,7 +1292,7 @@ async def test_get_organization_daily_activity_non_admin_without_org_admin_role_
)
assert get_daily_activity_mock.call_args.kwargs["entity_id"] == []
assert org_table_find_many.call_args.kwargs["where"] == {"organization_id": {"in": []}}
org_table_find_many.assert_not_awaited()
@pytest.mark.asyncio

View file

@ -14603,129 +14603,6 @@ async def test_new_team_batch_enqueued_token_limit_rejected_for_non_admin():
assert "on a team" in str(exc.value.message)
@pytest.mark.asyncio
async def test_get_team_daily_activity_aggregated_scopes_and_flags(mock_db_client):
"""The aggregated endpoint must apply the same non-admin key scoping as the
paginated one and request the per-team entity breakdown with the caller's
timezone, so the Team Usage UI gets every day in one response."""
from litellm.proxy.management_endpoints.team_endpoints import (
get_team_daily_activity_aggregated,
)
user_id = "test_user_123"
team_id = "test_team_456"
user_api_key_dict = UserAPIKeyAuth(
user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER
)
mock_user_info = LiteLLM_UserTable(
user_id=user_id,
teams=[team_id],
max_budget=1000.0,
spend=0.0,
user_email="test@example.com",
user_role="internal_user",
)
mock_team_member = Member(user_id=user_id, role="user")
mock_team = MagicMock(spec=LiteLLM_TeamTable)
mock_team.team_id = team_id
mock_team.team_alias = "Test Team"
mock_team.members_with_roles = [mock_team_member]
mock_team.model_dump.return_value = {
"team_id": team_id,
"team_alias": "Test Team",
"members_with_roles": [{"user_id": user_id, "role": "user"}],
}
user_api_key_1 = MagicMock()
user_api_key_1.token = "user_key_1"
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team])
mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(
return_value=[user_api_key_1]
)
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_user_object",
new_callable=AsyncMock,
) as mock_get_user_object:
mock_get_user_object.return_value = mock_user_info
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated",
new_callable=AsyncMock,
) as mock_aggregated:
mock_aggregated.return_value = MagicMock()
await get_team_daily_activity_aggregated(
team_ids=team_id,
start_date="2024-01-01",
end_date="2024-01-31",
model=None,
api_key=None,
exclude_team_ids=None,
timezone=480,
user_api_key_dict=user_api_key_dict,
)
mock_aggregated.assert_called_once()
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 scope.timezone_offset_minutes == 480
assert scope.table.value == "litellm_dailyteamspend"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"start_date,end_date,expected_error",
[
("2020-01-01", "2026-12-31", "at most 400 days"),
("0000-01-01", "9999-12-31", "valid YYYY-MM-DD"),
("2024-06-01", "2024-01-01", "on or after"),
("not-a-date", "2024-01-31", "valid YYYY-MM-DD"),
(None, "2024-01-31", "start_date and end_date"),
],
)
async def test_get_team_daily_activity_aggregated_rejects_bad_ranges(
mock_db_client, start_date, end_date, expected_error
):
"""The aggregated endpoint has no pagination bounding its work, so an
unbounded or malformed range must 400 before any query runs."""
from litellm.proxy.management_endpoints.team_endpoints import (
get_team_daily_activity_aggregated,
)
with patch(
"litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated",
new_callable=AsyncMock,
) as mock_aggregated:
with pytest.raises(HTTPException) as exc_info:
await get_team_daily_activity_aggregated(
team_ids=None,
start_date=start_date,
end_date=end_date,
model=None,
api_key=None,
exclude_team_ids=None,
timezone=None,
user_api_key_dict=UserAPIKeyAuth(
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
),
)
assert exc_info.value.status_code == 400
assert expected_error in str(exc_info.value.detail)
mock_aggregated.assert_not_called()
def _wire_new_team_prisma(mock_db_client):
mock_db_client.jsonify_team_object = lambda db_data: db_data
mock_db_client.get_data = AsyncMock(return_value=None)

View file

@ -1140,15 +1140,7 @@
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 3
}
},
"src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts": {
"react-hooks/refs": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
"count": 2
}
},
"src/app/(dashboard)/users/_components/BulkEditUsers.tsx": {
@ -1279,11 +1271,6 @@
"count": 1
}
},
"src/components/EntityUsageExport/utils.ts": {
"max-params": {
"count": 3
}
},
"src/components/GuardrailSettingsView.tsx": {
"no-nested-ternary": {
"count": 1
@ -1786,13 +1773,13 @@
"count": 1
},
"max-params": {
"count": 21
"count": 15
},
"no-nested-ternary": {
"count": 5
},
"no-restricted-syntax": {
"count": 147
"count": 146
},
"prefer-const": {
"count": 31

View file

@ -1,9 +1,18 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DailyData, KeyMetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
import type { components } from "@/lib/http/schema";
import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import type { DailyActivityRange } from "./useDailyActivityRange";
const mockCacheLeakageKeysCall = vi.fn();
vi.mock("@/components/networking", () => ({
cacheLeakageKeysCall: (...args: unknown[]) => mockCacheLeakageKeysCall(...args),
}));
vi.mock("@/components/shared/advanced_date_picker", () => ({
__esModule: true,
default: () => <div data-testid="date-picker" />,
@ -11,8 +20,9 @@ vi.mock("@/components/shared/advanced_date_picker", () => ({
import CacheLeakageCard from "./CacheLeakageCard";
const baseMetrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
const baseMetrics = (overrides: Partial<SpendMetrics>): components["schemas"]["SpendMetrics"] => ({
spend: 0,
flat_cost: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
@ -21,27 +31,22 @@ const baseMetrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
compression_saved_tokens: 0,
compression_savings_spend: 0,
prompt_caching_savings_spend: 0,
gateway_injected_caching_savings_spend: 0,
autorouter_savings_spend: 0,
total_response_time_ms: 0,
timed_requests: 0,
...overrides,
});
const key = (alias: string, metrics: Partial<SpendMetrics>): KeyMetricWithMetadata => ({
const keyRow = (hash: string, alias: string, metrics: Partial<SpendMetrics>): KeySpendActivityRow => ({
api_key: hash,
metrics: baseMetrics(metrics),
metadata: { key_alias: alias, team_id: null },
});
const dayWithKeys = (date: string, apiKeys: Record<string, KeyMetricWithMetadata>): DailyData => ({
date,
metrics: baseMetrics({}),
breakdown: {
models: {},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: apiKeys,
entities: {},
},
});
const dayWithModels = (date: string, models: Record<string, Partial<SpendMetrics>>): DailyData => ({
date,
metrics: baseMetrics({}),
@ -67,27 +72,37 @@ const renderWith = (results: DailyData[], overrides: Partial<DailyActivityRange>
dateValue: {},
onDateChange: vi.fn(),
results,
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
loading: false,
isFetchingMore: false,
progress: { currentPage: 1, totalPages: 1 },
cancelled: false,
failed: false,
cancel: vi.fn(),
scope: {
accessToken: "test-token",
startTime: new Date(2025, 0, 1),
endTime: new Date(2025, 0, 31),
userId: null,
apiKey: null,
},
...overrides,
}}
/>,
);
describe("CacheLeakageCard", () => {
it("ranks leaking keys by uncached prompt tokens and shows cache hit ratio", () => {
renderWith([
dayWithKeys("2026-07-12", {
"hash-caching": key("caching-key", { prompt_tokens: 1000, cache_read_input_tokens: 900 }),
"hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
}),
]);
beforeEach(() => {
mockCacheLeakageKeysCall.mockReset();
mockCacheLeakageKeysCall.mockResolvedValue({ api_keys: [] });
});
expect(screen.getByText("leaky-key")).toBeInTheDocument();
it("ranks leaking keys from the server-ranked key list and shows cache hit ratio", async () => {
mockCacheLeakageKeysCall.mockResolvedValue({
api_keys: [
keyRow("hash-caching", "caching-key", { prompt_tokens: 1000, cache_read_input_tokens: 900 }),
keyRow("hash-leaky", "leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
],
});
renderWith([]);
expect(await screen.findByText("leaky-key")).toBeInTheDocument();
expect(screen.getByText("0.0%")).toBeInTheDocument();
expect(screen.getByText("90.0%")).toBeInTheDocument();
[
@ -97,23 +112,42 @@ describe("CacheLeakageCard", () => {
].forEach((info) => expect(screen.getByLabelText(info)).toBeInTheDocument());
});
it("sorts by the clicked column, worst cache hit rate first", () => {
renderWith([
dayWithKeys("2026-07-12", {
"hash-a": key("alpha", {
it("asks the server for the key ranking under the activity scope", async () => {
renderWith([], {
scope: {
accessToken: "test-token",
startTime: new Date(2025, 0, 1),
endTime: new Date(2025, 0, 31),
userId: "u1",
apiKey: "hash-1",
},
});
await screen.findByText("No key usage in this range.");
expect(mockCacheLeakageKeysCall).toHaveBeenCalledWith(
expect.objectContaining({ entityIds: ["u1"], apiKey: "hash-1", includeCurrentUtcDay: true }),
);
});
it("sorts by the clicked column, worst cache hit rate first", async () => {
mockCacheLeakageKeysCall.mockResolvedValue({
api_keys: [
keyRow("hash-a", "alpha", {
prompt_tokens: 10000,
cache_read_input_tokens: 9000,
prompt_caching_savings_spend: 9.0,
}),
"hash-b": key("bravo", {
keyRow("hash-b", "bravo", {
prompt_tokens: 500,
cache_read_input_tokens: 50,
prompt_caching_savings_spend: 0.05,
}),
}),
]);
],
});
renderWith([]);
const firstDataRow = () => screen.getAllByRole("row")[1];
expect(await screen.findByText("alpha")).toBeInTheDocument();
expect(firstDataRow()).toHaveTextContent("alpha");
fireEvent.click(screen.getByText("Cache hit rate"));
@ -123,7 +157,7 @@ describe("CacheLeakageCard", () => {
expect(firstDataRow()).toHaveTextContent("alpha");
});
it("switches to the model view and lists models from every provider", () => {
it("switches to the model view and lists models from every provider", async () => {
renderWith([
dayWithModels("2026-07-12", {
"claude-sonnet-5": { prompt_tokens: 5000, cache_read_input_tokens: 0 },
@ -131,51 +165,32 @@ describe("CacheLeakageCard", () => {
}),
]);
fireEvent.click(screen.getByText("By model"));
fireEvent.click(await screen.findByText("By model"));
expect(screen.getByText("Cache leakage by model")).toBeInTheDocument();
expect(screen.getByText("claude-sonnet-5")).toBeInTheDocument();
expect(screen.getByText("vertex_ai/gemini-2.5-pro")).toBeInTheDocument();
});
it("shows an empty state when no key used tokens in the range", () => {
renderWith([dayWithKeys("2026-07-12", {})]);
it("shows an empty state when no key used tokens in the range", async () => {
renderWith([]);
expect(screen.getByText("No key usage in this range.")).toBeInTheDocument();
expect(await screen.findByText("No key usage in this range.")).toBeInTheDocument();
expect(screen.queryByRole("table")).not.toBeInTheDocument();
});
it("tells the user the table is still filling in while fallback pages stream", () => {
const day = dayWithKeys("2026-07-12", {
"hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
});
renderWith([day], { isFetchingMore: true });
it("reports a load failure instead of claiming the range is empty", async () => {
mockCacheLeakageKeysCall.mockRejectedValue(new Error("route unavailable"));
renderWith([]);
expect(screen.getByRole("table")).toBeInTheDocument();
expect(
screen.getByText("Data is still loading; rows and totals will update as the rest of the range arrives."),
).toBeInTheDocument();
expect(await screen.findByText("Could not load key usage for this range.")).toBeInTheDocument();
expect(screen.queryByText("No key usage in this range.")).not.toBeInTheDocument();
});
it("keeps the streaming note off while a fresh range loads over the previous range's rows", () => {
const day = dayWithKeys("2026-07-12", {
"hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
});
renderWith([day], { loading: true });
it("shows a loading state while the key ranking is in flight", () => {
mockCacheLeakageKeysCall.mockReturnValue(new Promise(() => {}));
renderWith([]);
expect(
screen.queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."),
).not.toBeInTheDocument();
});
it("drops the streaming note once the range has settled", () => {
const day = dayWithKeys("2026-07-12", {
"hash-leaky": key("leaky-key", { prompt_tokens: 10000, cache_read_input_tokens: 0 }),
});
renderWith([day]);
expect(
screen.queryByText("Data is still loading; rows and totals will update as the rest of the range arrives."),
).not.toBeInTheDocument();
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
});

View file

@ -8,8 +8,17 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { CacheLeakageDimension, CacheLeakageRow, computeCacheLeakage, pct, usd } from "./costOptimizationUtils";
import {
CacheLeakageDimension,
CacheLeakageRow,
computeCacheLeakage,
leakageRowsFromKeyRows,
netSavingsPerCachedToken,
pct,
usd,
} from "./costOptimizationUtils";
import { DailyActivityRange } from "./useDailyActivityRange";
import { useCacheLeakageKeys } from "./useCacheLeakageKeys";
interface CacheLeakageCardProps {
activity: DailyActivityRange;
@ -80,11 +89,20 @@ const SortableHead = ({
};
const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
const { results, loading, isFetchingMore } = activity;
const { results, loading } = activity;
const [dimension, setDimension] = useState<CacheLeakageDimension>("key");
const [sort, setSort] = useState<SortState>({ column: "potentialSavings", dir: "desc" });
const leakage = useMemo(() => computeCacheLeakage(results, dimension), [results, dimension]);
const rows = useMemo(() => [...leakage.rows].sort((a, b) => compareRows(a, b, sort)), [leakage.rows, sort]);
const leakageRate = useMemo(() => netSavingsPerCachedToken(results), [results]);
const keyLeakage = useCacheLeakageKeys(activity, dimension === "key");
const unsortedRows = useMemo(
() =>
dimension === "key"
? leakageRowsFromKeyRows(keyLeakage.rows, leakageRate)
: computeCacheLeakage(results, "model").rows,
[dimension, keyLeakage.rows, leakageRate, results],
);
const rows = useMemo(() => [...unsortedRows].sort((a, b) => compareRows(a, b, sort)), [unsortedRows, sort]);
const rowsLoading = dimension === "key" ? keyLeakage.loading : loading;
const onSort = (column: SortColumn) =>
setSort((prev) =>
@ -96,6 +114,10 @@ const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
const subject = dimension === "model" ? "Models" : "Keys";
const firstColumn = dimension === "model" ? "Model" : "Key";
const emptyNoun = dimension === "model" ? "model" : "key";
const emptyMessage =
dimension === "key" && keyLeakage.failed
? "Could not load key usage for this range."
: `No ${emptyNoun} usage in this range.`;
return (
<TooltipProvider delay={300}>
@ -119,14 +141,9 @@ const CacheLeakageCard: React.FC<CacheLeakageCardProps> = ({ activity }) => {
</Tabs>
</CardHeader>
<CardContent>
{rows.length > 0 && isFetchingMore && (
<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.
</p>
)}
{rows.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{loading || isFetchingMore ? "Loading..." : `No ${emptyNoun} usage in this range.`}
{rowsLoading ? "Loading..." : emptyMessage}
</p>
) : (
<Table>

View file

@ -3,8 +3,7 @@ import { fireEvent, render, waitFor, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const mockUserDailyActivityCall = vi.fn();
const mockUserDailyActivityAggregatedCall = vi.fn();
const mockDailyActivityAggregatedCall = vi.fn();
const { useAuthorizedMock, mockToolSpendResponse } = vi.hoisted(() => ({
useAuthorizedMock: vi.fn(),
mockToolSpendResponse: { by_tool: [], daily: [], start_date: null, end_date: null },
@ -15,8 +14,8 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
}));
vi.mock("@/components/networking", () => ({
userDailyActivityCall: (...args: unknown[]) => mockUserDailyActivityCall(...args),
userDailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args),
dailyActivityAggregatedCall: (...args: unknown[]) => mockDailyActivityAggregatedCall(...args),
cacheLeakageKeysCall: vi.fn().mockResolvedValue({ api_keys: [] }),
getToolSpend: vi.fn().mockResolvedValue(mockToolSpendResponse),
getGeneralSettingsCall: vi.fn().mockResolvedValue([]),
organizationListCall: vi.fn().mockResolvedValue([]),
@ -53,7 +52,7 @@ const singlePage = {
describe("CostOptimizationView daily activity", () => {
it("fetches daily activity once for the page and shares it with every tab that needs it", async () => {
mockUserDailyActivityAggregatedCall.mockResolvedValue(singlePage);
mockDailyActivityAggregatedCall.mockResolvedValue(singlePage);
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "proxy_admin" });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@ -63,25 +62,17 @@ describe("CostOptimizationView daily activity", () => {
</QueryClientProvider>,
);
await waitFor(() => expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1));
await waitFor(() => expect(mockDailyActivityAggregatedCall).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole("tab", { name: "Prompt Caching" }));
await screen.findByTestId("caching-settings");
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(1);
expect(mockUserDailyActivityCall).not.toHaveBeenCalled();
expect(screen.queryByText(/Currently fetching spend data/)).not.toBeInTheDocument();
expect(mockDailyActivityAggregatedCall).toHaveBeenCalledTimes(1);
});
it("shows the fetch-progress banner while the paginated fallback streams pages in", async () => {
mockUserDailyActivityAggregatedCall.mockReset();
mockUserDailyActivityCall.mockReset();
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable"));
mockUserDailyActivityCall.mockImplementation((...args: unknown[]) =>
args[3] === 1
? Promise.resolve({ results: [], metadata: { total_pages: 3, has_more: true, page: 1 } })
: new Promise(() => {}),
);
it("surfaces a failure alert when the aggregated fetch fails", async () => {
mockDailyActivityAggregatedCall.mockReset();
mockDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable"));
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "proxy_admin" });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@ -91,7 +82,6 @@ describe("CostOptimizationView daily activity", () => {
</QueryClientProvider>,
);
expect(await screen.findByText(/Currently fetching spend data: fetched 1 \/ 3 pages/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
expect(await screen.findByText(/Fetching spend data failed/)).toBeInTheDocument();
});
});

View file

@ -11,12 +11,7 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
vi.mock("@/components/networking", () => ({
organizationListCall: vi.fn().mockResolvedValue([]),
userDailyActivityCall: vi
.fn()
.mockResolvedValue({ results: [], metadata: { total_pages: 1, has_more: false, page: 1 } }),
userDailyActivityAggregatedCall: vi
.fn()
.mockResolvedValue({ results: [], metadata: { total_pages: 1, has_more: false, page: 1 } }),
dailyActivityAggregatedCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }),
}));
vi.mock("./UsageTab", () => ({ __esModule: true, default: () => <div data-testid="usage-tab" /> }));

View file

@ -4,7 +4,7 @@ import React from "react";
import { Info, PiggyBank } from "lucide-react";
import useCan from "@/app/(dashboard)/hooks/useCan";
import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts";
import { Alert, AlertDescription } from "@/components/shared/Alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { PageHeader } from "@/components/shared/PageHeader";
import UsageTab from "./UsageTab";
@ -84,13 +84,14 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
</p>
</div>
<PaginationStatusAlerts
isFetchingMore={activity.isFetchingMore}
cancelled={activity.cancelled}
failed={activity.failed}
progress={activity.progress}
cancel={activity.cancel}
/>
{activity.failed && (
<Alert variant="error">
<AlertDescription className="text-inherit">
Fetching spend data failed, so the savings below may be empty rather than final. Reload the page to try
again.
</AlertDescription>
</Alert>
)}
<TabsContent value="usage" keepMounted={visitedTabs.includes("usage")}>
<UsageTab accessToken={accessToken} activity={activity} />
</TabsContent>

View file

@ -1,6 +1,8 @@
import { fireEvent, render, waitFor, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
const mockGetGeneralSettingsCall = vi.fn();
vi.mock("@/components/networking", () => ({
@ -46,12 +48,16 @@ describe("PromptCachingTab", () => {
dateValue: {},
onDateChange: vi.fn(),
results: [],
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
loading: false,
isFetchingMore: false,
progress: { currentPage: 1, totalPages: 1 },
cancelled: false,
failed: false,
cancel: vi.fn(),
scope: {
accessToken: "test-token",
startTime: null,
endTime: null,
userId: null,
apiKey: null,
},
};
render(<PromptCachingTab accessToken="test-token" activity={activity} />);

View file

@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ToolSpendResponse } from "@/components/networking";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
const mockGetToolSpend = vi.fn();
@ -119,12 +120,16 @@ const renderWith = (results: DailyData[], options: RenderOptions = {}) => {
dateValue: { from, to },
onDateChange: vi.fn(),
results,
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
loading: false,
isFetchingMore: false,
progress: { currentPage: 1, totalPages: 1 },
cancelled: false,
failed: false,
cancel: vi.fn(),
scope: {
accessToken: "test-token",
startTime: from,
endTime: to,
userId: null,
apiKey: null,
},
}}
/>,
);

View file

@ -44,7 +44,7 @@ const EMPTY_TOOL_SPEND: ToolSpendResponse = {
const isoDay = (d: Date): string => d.toISOString().slice(0, 10);
const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
const { dateValue, onDateChange, results, loading, isFetchingMore } = activity;
const { dateValue, onDateChange, results, loading } = activity;
const startTime = dateValue.from ?? null;
const endTime = dateValue.to ?? null;
@ -130,7 +130,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
<AdvancedDatePicker value={dateValue} onValueChange={onDateChange} />
</div>
<SavingsTiles results={results} isLoading={loading || isFetchingMore} />
<SavingsTiles results={results} isLoading={loading} />
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">

View file

@ -1,3 +1,4 @@
import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi";
import { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import { ToolSpendDailyEntry, ToolSpendEntry } from "@/components/networking";
import { formatNumberWithCommas } from "@/utils/dataUtils";
@ -101,6 +102,71 @@ const aggregateByModel = (results: readonly DailyData[]): Map<string, LeakageAcc
return byModel;
};
export const netSavingsPerCachedToken = (results: readonly DailyData[]): number | null => {
const totals = [...aggregateByModel(results).values()].reduce(
(agg, a) => ({
cachedTokens: agg.cachedTokens + a.cacheReadTokens + a.cacheCreationTokens,
realizedCachingSavings: agg.realizedCachingSavings + a.realizedCachingSavings,
}),
{ cachedTokens: 0, realizedCachingSavings: 0 },
);
const rate = totals.cachedTokens > 0 ? totals.realizedCachingSavings / totals.cachedTokens : null;
return rate != null && rate > 0 ? rate : null;
};
const toLeakageRow = (
id: string,
a: {
alias: string | null;
teamId: string | null;
promptTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
},
rate: number | null,
dimension: CacheLeakageDimension,
): CacheLeakageRow => {
const uncachedPromptTokens = Math.max(0, a.promptTokens - a.cacheReadTokens - a.cacheCreationTokens);
return {
id,
label: dimension === "model" ? id : a.alias ?? `${id.slice(0, 8)}...`,
sublabel: dimension === "model" ? null : a.teamId,
uncachedPromptTokens,
cacheHitRatio: a.promptTokens > 0 ? a.cacheReadTokens / a.promptTokens : 0,
potentialSavings: rate != null ? uncachedPromptTokens * rate : null,
};
};
const sortAndLimit = (rows: CacheLeakageRow[], rate: number | null, limit: number): CacheLeakageRow[] =>
rows
.filter((row) => row.uncachedPromptTokens > 0)
.sort((x, y) =>
rate != null
? (y.potentialSavings ?? 0) - (x.potentialSavings ?? 0)
: y.uncachedPromptTokens - x.uncachedPromptTokens,
)
.slice(0, limit);
export const leakageRowsFromKeyRows = (
rows: readonly KeySpendActivityRow[],
rate: number | null,
limit = 10,
): CacheLeakageRow[] =>
sortAndLimit(
rows.map((row) => {
const metrics = {
alias: row.metadata.key_alias ?? null,
teamId: row.metadata.team_id ?? null,
promptTokens: row.metrics.prompt_tokens ?? 0,
cacheReadTokens: row.metrics.cache_read_input_tokens ?? 0,
cacheCreationTokens: row.metrics.cache_creation_input_tokens ?? 0,
};
return toLeakageRow(row.api_key, metrics, rate, "key");
}),
rate,
limit,
);
export const computeCacheLeakage = (
results: readonly DailyData[],
dimension: CacheLeakageDimension = "key",
@ -123,27 +189,13 @@ export const computeCacheLeakage = (
// A non-positive rate prices no leakage: there is no saving to extrapolate from
const rate = netSavingsPerCachedToken != null && netSavingsPerCachedToken > 0 ? netSavingsPerCachedToken : null;
const rows: CacheLeakageRow[] = [...byEntity.entries()]
.map(([id, a]) => {
const uncachedPromptTokens = Math.max(0, a.promptTokens - a.cacheReadTokens - a.cacheCreationTokens);
return {
id,
label: dimension === "model" ? id : a.alias ?? `${id.slice(0, 8)}...`,
sublabel: dimension === "model" ? null : a.teamId,
uncachedPromptTokens,
cacheHitRatio: a.promptTokens > 0 ? a.cacheReadTokens / a.promptTokens : 0,
potentialSavings: rate != null ? uncachedPromptTokens * rate : null,
};
})
.filter((row) => row.uncachedPromptTokens > 0);
const sorted = rows.sort((x, y) =>
rate != null
? (y.potentialSavings ?? 0) - (x.potentialSavings ?? 0)
: y.uncachedPromptTokens - x.uncachedPromptTokens,
const rows = sortAndLimit(
[...byEntity.entries()].map(([id, a]) => toLeakageRow(id, a, rate, dimension)),
rate,
limit,
);
return { rows: sorted.slice(0, limit), netSavingsPerCachedToken };
return { rows, netSavingsPerCachedToken };
};
export interface DailyToolSpendPoint {

View file

@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from "react";
import { cacheLeakageKeysCall } from "@/components/networking";
import type { KeySpendActivityRow } from "@/components/UsagePage/dailyActivityApi";
import type { DailyActivityRange } from "./useDailyActivityRange";
interface CacheLeakageKeysResult {
rows: KeySpendActivityRow[];
loading: boolean;
failed: boolean;
}
interface SettledKeys {
key: string;
rows: KeySpendActivityRow[];
failed: boolean;
}
export const useCacheLeakageKeys = (range: DailyActivityRange, enabled: boolean): CacheLeakageKeysResult => {
const { accessToken, startTime, endTime, userId, apiKey } = range.scope;
const [settled, setSettled] = useState<SettledKeys | null>(null);
const requestIdRef = useRef(0);
const hasTimeRange = !!startTime && !!endTime;
const scopeReady = enabled && !!accessToken && hasTimeRange;
const scopeKey = scopeReady ? JSON.stringify([accessToken, startTime, endTime, userId, apiKey]) : null;
useEffect(() => {
if (!scopeKey) return;
if (!accessToken || !startTime || !endTime) return;
const requestId = ++requestIdRef.current;
const isStale = () => requestIdRef.current !== requestId;
const request = {
accessToken,
startTime,
endTime,
entityIds: userId ? [userId] : null,
apiKey,
includeCurrentUtcDay: true,
};
cacheLeakageKeysCall(request)
.then((response) => {
if (isStale()) return;
setSettled({ key: scopeKey, rows: response.api_keys, failed: false });
})
.catch((error) => {
if (isStale()) return;
console.error("Failed to fetch cache leakage keys:", error);
setSettled({ key: scopeKey, rows: [], failed: true });
});
return () => {
requestIdRef.current++;
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- scopeKey serializes the scope
}, [scopeKey]);
const current = scopeKey !== null && settled?.key === scopeKey ? settled : null;
return {
rows: current?.rows ?? [],
loading: scopeKey !== null && current === null,
failed: current?.failed ?? false,
};
};

View file

@ -1,35 +1,35 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
const mockUsePaginatedDailyActivity = vi.fn();
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
const mockCancel = vi.fn();
const mockUseAggregatedDailyActivity = vi.fn();
vi.mock("@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity", () => ({
usePaginatedDailyActivity: (args: unknown) => {
mockUsePaginatedDailyActivity(args);
vi.mock("@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity", () => ({
useAggregatedDailyActivity: (options: unknown) => {
mockUseAggregatedDailyActivity(options);
return {
data: { results: [] },
data: { results: [], metadata: EMPTY_DAILY_ACTIVITY_METADATA },
loading: false,
isFetchingMore: false,
progress: { currentPage: 4, totalPages: 9 },
cancelled: false,
failed: false,
coversRange: true,
cancel: mockCancel,
};
},
}));
vi.mock("@/components/networking", () => ({
userDailyActivityCall: vi.fn(),
userDailyActivityAggregatedCall: vi.fn(),
dailyActivityAggregatedCall: vi.fn().mockResolvedValue({ results: [], metadata: {} }),
}));
import { userDailyActivityAggregatedCall } from "@/components/networking";
import { dailyActivityAggregatedCall } from "@/components/networking";
import { useActivityDateRange, useDailyActivityRange } from "./useDailyActivityRange";
const argsOfLastCall = () => mockUsePaginatedDailyActivity.mock.calls.at(-1)?.[0].args as unknown[];
interface CapturedOptions {
fetch: () => Promise<unknown>;
enabled: boolean;
deps: unknown[];
}
const lastOptions = () => mockUseAggregatedDailyActivity.mock.calls.at(-1)?.[0] as CapturedOptions;
describe("useDailyActivityRange", () => {
it("offers date-range state without starting a daily-activity query", () => {
@ -37,49 +37,53 @@ describe("useDailyActivityRange", () => {
expect(result.current.dateValue.from).toBeInstanceOf(Date);
expect(result.current.dateValue.to).toBeInstanceOf(Date);
expect(mockUsePaginatedDailyActivity).not.toHaveBeenCalled();
expect(mockUseAggregatedDailyActivity).not.toHaveBeenCalled();
});
it("queries every user's activity for an admin", () => {
it("fetches every user's activity for an admin through the aggregated endpoint", async () => {
renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));
expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), null, true, null]);
await lastOptions().fetch();
expect(dailyActivityAggregatedCall).toHaveBeenCalledWith(
"user",
expect.objectContaining({
accessToken: "test-token",
entityIds: null,
includeCurrentUtcDay: true,
}),
);
});
it("scopes the query to the caller for a non-admin", () => {
it("scopes the query to the caller for a non-admin", async () => {
renderHook(() => useDailyActivityRange("test-token", "u1", "internal_user"));
expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true, null]);
await lastOptions().fetch();
expect(dailyActivityAggregatedCall).toHaveBeenCalledWith("user", expect.objectContaining({ entityIds: ["u1"] }));
});
it.each(["org_admin", "Org Admin"])(
"scopes the query to the caller for %s, who has no admin view on this endpoint",
(role) => {
async (role) => {
renderHook(() => useDailyActivityRange("test-token", "u1", role));
expect(argsOfLastCall()).toEqual(["test-token", expect.any(Date), expect.any(Date), "u1", true, null]);
await lastOptions().fetch();
expect(dailyActivityAggregatedCall).toHaveBeenCalledWith("user", expect.objectContaining({ entityIds: ["u1"] }));
},
);
it("fetches through the single-shot aggregated endpoint first so days never fragment across pages", () => {
renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));
expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith(
expect.objectContaining({ aggregatedFetchFn: userDailyActivityAggregatedCall }),
);
});
it("forwards the pagination progress and cancel affordances instead of dropping them", () => {
const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "proxy_admin"));
expect(result.current.progress).toEqual({ currentPage: 4, totalPages: 9 });
expect(result.current.cancelled).toBe(false);
expect(result.current.cancel).toBe(mockCancel);
});
it("stays disabled until an access token is available", () => {
renderHook(() => useDailyActivityRange(null, "u1", "proxy_admin"));
expect(mockUsePaginatedDailyActivity).toHaveBeenLastCalledWith(expect.objectContaining({ enabled: false }));
expect(lastOptions().enabled).toBe(false);
});
it("exposes the request scope so sibling hooks fetch under the same filters", () => {
const { result } = renderHook(() => useDailyActivityRange("test-token", "u1", "internal_user"));
expect(result.current.scope).toMatchObject({
accessToken: "test-token",
userId: "u1",
apiKey: null,
});
});
});

View file

@ -1,9 +1,15 @@
import { useMemo, useState } from "react";
import { userDailyActivityAggregatedCall, userDailyActivityCall } from "@/components/networking";
import { dailyActivityAggregatedCall } from "@/components/networking";
import {
EMPTY_DAILY_ACTIVITY_METADATA,
toDailyData,
type DailyActivityMetadata,
type DailyActivityRequest,
} from "@/components/UsagePage/dailyActivityApi";
import { DailyData } from "@/components/UsagePage/types";
import { spendScopeUserId } from "@/utils/roles";
import { usePaginatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity";
import { useAggregatedDailyActivity } from "@/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity";
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
@ -12,30 +18,22 @@ export interface DateRange {
to?: Date;
}
export interface DailyActivityScope {
accessToken: string | null;
startTime: Date | null;
endTime: Date | null;
userId: string | null;
apiKey: string | null;
}
export interface DailyActivityRange {
dateValue: DateRange;
onDateChange: (value: DateRange) => void;
results: DailyData[];
metadata: DailyActivityMetadata;
loading: boolean;
isFetchingMore: boolean;
progress: { currentPage: number; totalPages: number };
cancelled: boolean;
failed: boolean;
cancel: () => void;
}
/**
* Which slice of daily activity to read. Both fields are passed straight through to the
* endpoint as filters, so the caller — not this hook — decides what the viewer may see.
*
* `userId: null` asks for the whole proxy, which the backend only honours for admins;
* a non-admin must send its own id or the request is rejected. That role decision lives in
* `useDailyActivityRange` below rather than in here, so a caller scoping to one key is not
* silently re-scoped to a user as well.
*/
export interface DailyActivityScope {
userId: string | null;
apiKey?: string | null;
scope: DailyActivityScope;
}
export type ActivityDateRange = Pick<DailyActivityRange, "dateValue" | "onDateChange">;
@ -47,39 +45,49 @@ export const useActivityDateRange = (): ActivityDateRange => {
return { dateValue, onDateChange: setDateValue };
};
export interface ScopedActivityInput {
userId: string | null;
apiKey?: string | null;
}
export const useScopedDailyActivityRange = (
accessToken: string | null,
scope: DailyActivityScope,
scope: ScopedActivityInput,
{ dateValue, onDateChange }: ActivityDateRange,
): DailyActivityRange => {
const startTime = dateValue.from ?? null;
const endTime = dateValue.to ?? null;
const { userId, apiKey = null } = scope;
const activityQueryOptions = {
fetchFn: userDailyActivityCall,
aggregatedFetchFn: userDailyActivityAggregatedCall,
// Positional, and read by two functions whose signatures diverge at index 3: the paginated
// call takes `page` there (injected by the hook) and the aggregated one does not. Anything
// appended here must therefore be appended to BOTH networking signatures, in this order.
args: [accessToken, startTime, endTime, userId, true, apiKey],
enabled: !!accessToken && !!startTime && !!endTime,
};
const { data, loading, isFetchingMore, progress, cancelled, failed, coversRange, cancel } =
usePaginatedDailyActivity(activityQueryOptions);
const readUnavailable = failed || cancelled;
const waitingForRange = activityQueryOptions.enabled && !coversRange && !readUnavailable;
const request = useMemo<DailyActivityRequest | null>(
() =>
accessToken && startTime && endTime
? {
accessToken,
startTime,
endTime,
entityIds: userId ? [userId] : null,
apiKey,
includeCurrentUtcDay: true,
}
: null,
[accessToken, startTime, endTime, userId, apiKey],
);
const { data, loading, failed } = useAggregatedDailyActivity({
fetch: () => dailyActivityAggregatedCall("user", request as DailyActivityRequest),
enabled: request !== null,
deps: [accessToken, startTime, endTime, userId, apiKey],
});
return {
dateValue,
onDateChange,
results: data.results as DailyData[],
loading: loading || waitingForRange,
isFetchingMore,
progress,
cancelled,
results: toDailyData(data),
metadata: data.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA,
loading,
failed,
cancel,
scope: { accessToken, startTime, endTime, userId, apiKey },
};
};

View file

@ -5,7 +5,13 @@ import type { ReactNode } from "react";
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
import useTeams from "@/app/(dashboard)/hooks/useTeams";
import * as networking from "@/components/networking";
import type { DailyData, KeyMetadata, KeyMetricWithMetadata, SpendMetrics } from "@/components/UsagePage/types";
import type {
DailyData,
KeyMetadata,
KeyMetricWithMetadata,
ModelActivityData,
SpendMetrics,
} from "@/components/UsagePage/types";
import EntityUsage from "./EntityUsage";
import { getGlobalTopKeys, getTopAPIKeys } from "./entityUsageAggregations";
@ -51,21 +57,31 @@ beforeAll(() => {
// Mock the networking module
vi.mock("@/components/networking", () => ({
tagDailyActivityCall: vi.fn(),
teamDailyActivityCall: vi.fn(),
teamDailyActivityAggregatedCall: vi.fn(),
organizationDailyActivityCall: vi.fn(),
customerDailyActivityCall: vi.fn(),
agentDailyActivityCall: vi.fn(),
userDailyActivityCall: vi.fn(),
dailyActivityAggregatedCall: vi.fn(),
dailyActivityKeyPageCall: vi.fn(),
dailyActivityKeySearchCall: vi.fn(),
dailyActivityModelTopKeysCall: vi.fn(),
dailyActivityExportCall: vi.fn(),
}));
// Mock the child components to simplify testing
vi.mock("@/components/activity_metrics", () => ({
ActivityMetrics: ({ modelMetrics }: { modelMetrics?: { __source?: string } }) => (
ActivityMetrics: ({
modelMetrics,
summaryMetrics,
summaryTitle = "Overall Usage",
fetchTopApiKeys,
}: {
modelMetrics?: { __source?: string };
summaryMetrics?: ModelActivityData;
summaryTitle?: string;
fetchTopApiKeys?: (model: string) => Promise<unknown>;
}) => (
<div>
<span>Activity Metrics</span>
<span>{`metrics-source:${modelMetrics?.__source ?? "none"}`}</span>
{summaryMetrics !== undefined && <span>{summaryTitle}</span>}
{fetchTopApiKeys !== undefined && <span>{`top-keys-fetcher:${modelMetrics?.__source ?? "none"}`}</span>}
</div>
),
processActivityData: (_data: unknown, key: string) => ({ __source: key }),
@ -126,7 +142,9 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
}));
vi.mock("@/components/common_components/team_multi_select", () => ({
default: () => <div>Team Multi Select</div>,
default: ({ onChange }: { onChange: (value: string[]) => void }) => (
<button onClick={() => onChange(["team-1"])}>Team Multi Select</button>
),
}));
// Mock useTeams hook
@ -138,13 +156,22 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
}));
describe("EntityUsage", () => {
const mockTagDailyActivityCall = vi.mocked(networking.tagDailyActivityCall);
const mockTeamDailyActivityCall = vi.mocked(networking.teamDailyActivityCall);
const mockTeamDailyActivityAggregatedCall = vi.mocked(networking.teamDailyActivityAggregatedCall);
const mockOrganizationDailyActivityCall = vi.mocked(networking.organizationDailyActivityCall);
const mockCustomerDailyActivityCall = vi.mocked(networking.customerDailyActivityCall);
const mockAgentDailyActivityCall = vi.mocked(networking.agentDailyActivityCall);
const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall);
const mockDailyActivityAggregatedCall = vi.mocked(networking.dailyActivityAggregatedCall);
const mockDailyActivityKeyPageCall = vi.mocked(networking.dailyActivityKeyPageCall);
const mockTagDailyActivityCall = vi.fn();
const mockTeamDailyActivityCall = vi.fn();
const mockOrganizationDailyActivityCall = vi.fn();
const mockCustomerDailyActivityCall = vi.fn();
const mockAgentDailyActivityCall = vi.fn();
const mockUserDailyActivityCall = vi.fn();
const entityMocks: Record<string, ReturnType<typeof vi.fn>> = {
tag: mockTagDailyActivityCall,
team: mockTeamDailyActivityCall,
organization: mockOrganizationDailyActivityCall,
customer: mockCustomerDailyActivityCall,
agent: mockAgentDailyActivityCall,
user: mockUserDailyActivityCall,
};
const mockUseInfiniteUsers = vi.mocked(useInfiniteUsers);
const infiniteUsersResult = (users: { user_id: string; user_alias: string | null; user_email: string | null }[]) =>
@ -440,16 +467,25 @@ describe("EntityUsage", () => {
};
beforeEach(() => {
mockTagDailyActivityCall.mockClear();
mockTeamDailyActivityCall.mockClear();
mockTeamDailyActivityAggregatedCall.mockClear();
mockOrganizationDailyActivityCall.mockClear();
mockCustomerDailyActivityCall.mockClear();
mockAgentDailyActivityCall.mockClear();
mockUserDailyActivityCall.mockClear();
mockDailyActivityAggregatedCall.mockReset();
mockDailyActivityKeyPageCall.mockReset();
const emptyKeyPage = {
api_keys: [],
total_api_keys: 0,
offset: 0,
limit: 50,
};
mockDailyActivityKeyPageCall.mockResolvedValue(emptyKeyPage);
mockDailyActivityAggregatedCall.mockImplementation((entity, request) =>
(
entityMocks[entity] as unknown as (
req: typeof request,
) => ReturnType<typeof networking.dailyActivityAggregatedCall>
)(request),
);
Object.values(entityMocks).forEach((mock) => mock.mockClear());
mockTagDailyActivityCall.mockResolvedValue(mockSpendData);
mockTeamDailyActivityCall.mockResolvedValue(mockSpendData);
mockTeamDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData);
mockCustomerDailyActivityCall.mockResolvedValue(mockSpendData);
mockAgentDailyActivityCall.mockResolvedValue(mockAgentSpendData);
@ -534,7 +570,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
// Check that it shows team-specific label
@ -650,6 +686,36 @@ describe("EntityUsage", () => {
expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument();
});
it("loads key pages separately from the aggregate using the current entity scope", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByText("Team Multi Select"));
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalledTimes(2);
});
fireEvent.click(screen.getByText("Key Activity"));
await waitFor(() => {
expect(mockDailyActivityKeyPageCall).toHaveBeenCalledWith(
"team",
expect.objectContaining({ entityIds: ["team-1"] }),
0,
50,
);
});
expect(mockDailyActivityAggregatedCall.mock.lastCall?.[1]).not.toHaveProperty("apiKeyLimit");
const pageCallsBeforeFilterChange = mockDailyActivityKeyPageCall.mock.calls.length;
fireEvent.click(screen.getByText("Team Multi Select"));
await waitFor(() => {
expect(mockDailyActivityKeyPageCall.mock.calls.length).toBeGreaterThan(pageCallsBeforeFilterChange);
});
expect(mockDailyActivityKeyPageCall.mock.lastCall?.[1]).toEqual(expect.objectContaining({ entityIds: ["team-1"] }));
});
// 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.
const isShowing = (element: HTMLElement): boolean => {
@ -671,7 +737,7 @@ describe("EntityUsage", () => {
const NON_TEAM_PANELS: [string, string][] = [
["Cost", "Tag Spend Overview"],
["Model Activity", "metrics-source:model_groups"],
["Key Activity", "metrics-source:api_keys"],
["Key Activity", "Overall Usage"],
["Endpoint Activity", "Endpoint Usage Panel"],
];
@ -697,7 +763,7 @@ describe("EntityUsage", () => {
["Cost", "Team Spend Overview"],
["Model Activity", "metrics-source:model_groups"],
["Agent Activity", "metrics-source:entities"],
["Key Activity", "metrics-source:api_keys"],
["Key Activity", "Overall Usage"],
["Endpoint Activity", "Endpoint Usage Panel"],
];
@ -705,7 +771,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
act(() => {
@ -878,7 +944,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
expect(screen.getByText("Agent Activity")).toBeInTheDocument();
@ -898,7 +964,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
expect(screen.getByText("Top Agents Driving Spend")).toBeInTheDocument();
@ -918,16 +984,56 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockAgentDailyActivityCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
1,
null,
);
expect(mockAgentDailyActivityCall).toHaveBeenCalledWith(expect.objectContaining({ accessToken: "test-token" }));
});
});
it("offers per-model top keys in Model Activity but not in the agent breakdown", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
act(() => {
fireEvent.click(screen.getByText("Model Activity"));
});
expect(showingCount("top-keys-fetcher:model_groups")).toBeGreaterThan(0);
act(() => {
fireEvent.click(screen.getByText("Agent Activity"));
});
expect(showingCount("metrics-source:entities")).toBeGreaterThan(0);
expect(screen.queryByText("top-keys-fetcher:entities")).not.toBeInTheDocument();
});
it("shows a loader instead of zero totals while the aggregate is in flight", async () => {
let resolveSpend: (value: typeof mockSpendData) => void = () => {};
mockTagDailyActivityCall.mockReturnValue(
new Promise<typeof mockSpendData>((resolve) => {
resolveSpend = resolve;
}),
);
render(<EntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
});
expect(screen.getAllByText("Loading chart data...")).toHaveLength(2);
expect(screen.queryByText("Total Spend")).not.toBeInTheDocument();
expect(screen.queryByText("Overall Usage")).not.toBeInTheDocument();
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
await act(async () => {
resolveSpend(mockSpendData);
});
expect(screen.queryByText("Loading chart data...")).not.toBeInTheDocument();
expect(screen.getByText("Overall Usage")).toBeInTheDocument();
expect(screen.getByText("Total Spend")).toBeInTheDocument();
expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0);
});
it("should not fetch agent activity data for non-team entity types", async () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
@ -942,7 +1048,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
const agentActivityTab = screen.getByText("Agent Activity");
@ -1098,7 +1204,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="team" userRole="Internal User" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
});
expect(screen.getByText("Team Spend Overview")).toBeInTheDocument();
@ -1155,7 +1261,7 @@ describe("EntityUsage", () => {
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
};
mockTeamDailyActivityAggregatedCall.mockResolvedValue({
mockTeamDailyActivityCall.mockResolvedValue({
...mockSpendData,
results: [
{
@ -1183,31 +1289,71 @@ describe("EntityUsage", () => {
expect(screen.getByText(/^top-models:Code Review Agent=/)).toBeInTheDocument();
});
it("uses the aggregated team endpoint and never drains paginated pages for teams", async () => {
it("uses the aggregated team endpoint and makes a single bounded request", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled();
expect(mockDailyActivityAggregatedCall).toHaveBeenCalledWith("team", expect.anything());
});
expect(mockTeamDailyActivityCall).not.toHaveBeenCalled();
expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "team")).toHaveLength(1);
await waitFor(() => {
expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0);
});
});
it("falls back to the paginated team endpoint when the aggregated call fails", async () => {
mockTeamDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable"));
it("does not scope the agent breakdown by the selected team ids", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockTeamDailyActivityCall).toHaveBeenCalled();
expect(mockAgentDailyActivityCall).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "Team Multi Select" }));
await waitFor(() => {
expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0);
const teamRequests = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "team");
expect(teamRequests.some((call) => call[1].entityIds?.includes("team-1"))).toBe(true);
});
const agentRequests = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "agent");
expect(agentRequests.length).toBeGreaterThan(0);
agentRequests.forEach((call) => {
expect(call[1].entityIds).toBeNull();
});
});
it("does not refetch agent activity when the team selection changes", async () => {
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(mockAgentDailyActivityCall).toHaveBeenCalled();
});
const agentCallsBefore = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "agent").length;
const teamCallsBefore = mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "team").length;
fireEvent.click(screen.getByRole("button", { name: "Team Multi Select" }));
await waitFor(() => {
expect(mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "team").length).toBeGreaterThan(
teamCallsBefore,
);
});
expect(mockDailyActivityAggregatedCall.mock.calls.filter((call) => call[0] === "agent")).toHaveLength(
agentCallsBefore,
);
});
it("surfaces a failure alert when the aggregated call fails instead of retrying other routes", async () => {
mockTeamDailyActivityCall.mockRejectedValue(new Error("aggregated unavailable"));
render(<EntityUsage {...defaultProps} entityType="team" />);
await waitFor(() => {
expect(screen.getAllByText(/Fetching spend data failed/).length).toBeGreaterThan(0);
});
expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "team")).toHaveLength(1);
});
describe("user filter (LIT-5654)", () => {
@ -1244,18 +1390,16 @@ describe("EntityUsage", () => {
const user = userEvent.setup();
await renderUserUsage();
expect(mockUserDailyActivityCall).toHaveBeenCalledWith("test-token", expect.any(Date), expect.any(Date), 1, null);
expect(mockUserDailyActivityCall).toHaveBeenCalledWith(
expect.objectContaining({ accessToken: "test-token", entityIds: null }),
);
await user.click(userCombobox());
await user.click(await screen.findByText("Alice (user-001)"));
await waitFor(() => {
expect(mockUserDailyActivityCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
1,
"user-001",
expect.objectContaining({ accessToken: "test-token", entityIds: ["user-001"] }),
);
});
@ -1264,11 +1408,7 @@ describe("EntityUsage", () => {
await waitFor(() => {
expect(mockUserDailyActivityCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
1,
null,
expect.objectContaining({ accessToken: "test-token", entityIds: null }),
);
});
});

View file

@ -6,7 +6,6 @@ import {
getTopAgents,
getTopAPIKeys,
getTopModels,
type ExtendedDailyData,
type ProviderSpendRow,
} from "./entityUsageAggregations";
import {
@ -23,27 +22,25 @@ import { formatNumberWithCommas } from "@/utils/dataUtils";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import { ChevronDown, ChevronRight, Info } from "lucide-react";
import type { ColumnDef } from "@tanstack/react-table";
import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts";
import { Alert, AlertDescription } from "@/components/shared/Alert";
import { ChartLoader } from "@/components/shared/chart_loader";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import React, { type ReactNode, useMemo, useState } from "react";
import React, { type ReactNode, useCallback, useMemo, useState } from "react";
import TeamMultiSelect from "@/components/common_components/team_multi_select";
import UserDropdown from "@/components/common_components/UserDropdown";
import { ActivityMetrics, processActivityData } from "@/components/activity_metrics";
import { UsageExportHeader } from "@/components/EntityUsageExport";
import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
import type { EntityType } from "@/components/EntityUsageExport/types";
import {
agentDailyActivityCall,
customerDailyActivityCall,
organizationDailyActivityCall,
tagDailyActivityCall,
teamDailyActivityAggregatedCall,
teamDailyActivityCall,
userDailyActivityCall,
} from "@/components/networking";
import { Logo } from "@/components/molecules/logo/Logo";
import { usePaginatedDailyActivity } from "../../hooks/usePaginatedDailyActivity";
import { useAggregatedDailyActivity } from "../../hooks/useAggregatedDailyActivity";
import { ENTITY_API } from "./entityFetchFns";
import {
EMPTY_DAILY_ACTIVITY_METADATA,
toDailyData,
type DailyActivityRequest,
} from "@/components/UsagePage/dailyActivityApi";
import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData";
import { EntityMetricWithMetadata } from "@/components/UsagePage/types";
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
import EndpointUsage from "../EndpointUsage/EndpointUsage";
@ -68,19 +65,6 @@ interface EntityMetrics {
metadata: Record<string, any>;
}
interface EntitySpendData {
results: ExtendedDailyData[];
metadata: {
total_spend: number;
total_flat_cost?: number;
total_ptu_hours?: number;
total_api_requests: number;
total_successful_requests: number;
total_failed_requests: number;
total_tokens: number;
};
}
export interface EntityList {
label: string;
value: string;
@ -98,21 +82,6 @@ interface EntityUsageProps {
isOrgAdmin?: boolean;
}
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
tag: tagDailyActivityCall,
team: teamDailyActivityCall,
organization: organizationDailyActivityCall,
customer: customerDailyActivityCall,
agent: agentDailyActivityCall,
user: userDailyActivityCall,
};
// Single-shot endpoints returning the whole range in one response; entity types
// without one fall back to page-draining the paginated endpoint.
const ENTITY_AGGREGATED_FETCH_FNS: Partial<Record<EntityType, (...args: any[]) => Promise<any>>> = {
team: teamDailyActivityAggregatedCall,
};
const ENTITY_CAPABILITIES: Partial<Record<EntityType, Capability>> = {
organization: "viewOrganizationUsage",
agent: "viewAgentUsage",
@ -128,6 +97,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
isOrgAdmin = false,
}) => {
const { teams } = useTeams();
const teamList = useMemo(() => teams ?? [], [teams]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [modelViewType, setModelViewType] = useState<ModelViewType>("groups");
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
@ -137,56 +107,103 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
const entityFilterArg = useMemo(() => {
if (entityType === "user") return selectedTags.length > 0 ? selectedTags[0] : null;
return selectedTags.length > 0 ? selectedTags : null;
}, [entityType, selectedTags]);
const fetchFn = ENTITY_FETCH_FNS[entityType];
const aggregatedFetchFn = ENTITY_AGGREGATED_FETCH_FNS[entityType];
const api = ENTITY_API[entityType];
const entityCapability = ENTITY_CAPABILITIES[entityType];
const canViewEntity = entityCapability === undefined || hasCapability(userRole, entityCapability, isOrgAdmin);
const showAgentBreakdown = entityType === "team" && hasCapability(userRole, "viewAgentUsage");
const hasRequestWindow = !!accessToken && !!startTime && !!endTime;
const enabled = hasRequestWindow && canViewEntity;
const request = useMemo<DailyActivityRequest | null>(
() =>
hasRequestWindow
? {
accessToken: accessToken as string,
startTime: startTime as Date,
endTime: endTime as Date,
entityIds: selectedTags.length > 0 ? selectedTags : null,
}
: null,
[hasRequestWindow, accessToken, startTime, endTime, selectedTags],
);
const agentRequest = useMemo<DailyActivityRequest | null>(
() =>
hasRequestWindow
? {
accessToken: accessToken as string,
startTime: startTime as Date,
endTime: endTime as Date,
entityIds: null,
}
: null,
[hasRequestWindow, accessToken, startTime, endTime],
);
const {
data: spendDataRaw,
isFetchingMore,
progress,
cancelled,
loading,
failed,
coversRange,
cancel,
} = usePaginatedDailyActivity({
fetchFn,
args: [accessToken, startTime, endTime, entityFilterArg],
enabled,
aggregatedFetchFn,
} = useAggregatedDailyActivity({
fetch: () => api.aggregated(request as DailyActivityRequest),
enabled: enabled && request !== null,
deps: [entityType, accessToken, startTime, endTime, selectedTags],
});
const spendData = spendDataRaw as unknown as EntitySpendData;
const spendData = useMemo(
() => ({
results: toDailyData(spendDataRaw),
metadata: spendDataRaw.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA,
}),
[spendDataRaw],
);
const summaryMetrics = useMemo(() => overallUsageMetrics(spendData.results, spendData.metadata), [spendData]);
const {
data: agentSpendDataRaw,
isFetchingMore: agentIsFetchingMore,
progress: agentProgress,
cancelled: agentCancelled,
loading: agentLoading,
failed: agentFailed,
cancel: agentCancel,
} = usePaginatedDailyActivity({
fetchFn: agentDailyActivityCall,
args: [accessToken, startTime, endTime, null],
enabled: enabled && showAgentBreakdown,
} = useAggregatedDailyActivity({
fetch: () => ENTITY_API.agent.aggregated(agentRequest as DailyActivityRequest),
enabled: enabled && showAgentBreakdown && agentRequest !== null,
deps: [accessToken, startTime, endTime, showAgentBreakdown],
});
const agentSpendData = agentSpendDataRaw as unknown as EntitySpendData;
const agentSpendData = useMemo(
() => ({
results: toDailyData(agentSpendDataRaw),
metadata: agentSpendDataRaw.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA,
}),
[agentSpendDataRaw],
);
const fetchTopApiKeys = useCallback(
(model: string) => api.modelTopKeys(request as DailyActivityRequest, model, modelViewType === "groups"),
[api, request, modelViewType],
);
const searchKeys = useCallback(
(query: string) => (request === null ? Promise.resolve({ api_keys: [] }) : api.searchKeys(request, query)),
[api, request],
);
const fetchKeyPage = useCallback(
(offset: number, limit: number) =>
request === null
? Promise.resolve({ api_keys: [], total_api_keys: 0, offset, limit })
: api.keyPage(request, offset, limit),
[api, request],
);
const fetchKeyDetail = useCallback(
(apiKey: string) =>
request === null
? Promise.resolve(undefined)
: api
.aggregated({ ...request, apiKey, apiKeyLimit: 1 })
.then((response) => keyDetailFromResponse(response, apiKey, teamList)),
[api, request, teamList],
);
const modelBreakdownKey = modelViewType === "groups" ? "model_groups" : "models";
const modelMetrics = processActivityData(spendData, modelBreakdownKey, teams || []);
const keyMetrics = processActivityData(spendData, "api_keys", teams || []);
const agentMetrics = showAgentBreakdown ? processActivityData(agentSpendData, "entities", teams || []) : {};
const modelMetrics = processActivityData(spendData, modelBreakdownKey, teamList);
const agentMetrics = showAgentBreakdown ? processActivityData(agentSpendData, "entities", teamList) : {};
const getAllTags = () => {
if (entityList) {
@ -401,7 +418,9 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
const modelViewTitle = modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models";
const costPanel = (
const costPanel = loading ? (
<ChartLoader />
) : (
<div className="grid grid-cols-2 gap-2 w-full">
<div className="col-span-2">
<ShadcnCard>
@ -604,11 +623,15 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Top Agents Driving Spend</h3>
<TopModelView
topModels={getTopAgents(agentSpendData.results, topAgentsLimit)}
topModelsLimit={topAgentsLimit}
setTopModelsLimit={setTopAgentsLimit}
/>
{agentLoading ? (
<ChartLoader />
) : (
<TopModelView
topModels={getTopAgents(agentSpendData.results, topAgentsLimit)}
topModelsLimit={topAgentsLimit}
setTopModelsLimit={setTopAgentsLimit}
/>
)}
</CardContent>
</ShadcnCard>
</div>
@ -659,46 +682,67 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
<div className="flex justify-end mt-2 mb-4">
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
</div>
<ActivityMetrics modelMetrics={modelMetrics} hidePromptCachingMetrics={entityType === "agent"} />
<ActivityMetrics
modelMetrics={modelMetrics}
hidePromptCachingMetrics={entityType === "agent"}
fetchTopApiKeys={request ? fetchTopApiKeys : undefined}
/>
</>
),
},
...(showAgentBreakdown
? [{ key: "agents", label: "Agent Activity", content: <ActivityMetrics modelMetrics={agentMetrics} /> }]
? [
{
key: "agents",
label: "Agent Activity",
content: <ActivityMetrics modelMetrics={agentMetrics} />,
},
]
: []),
{
key: "keys",
label: "Key Activity",
content: <KeyActivityPanel keyMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} />,
content: (
<KeyActivityPanel
summary={summaryMetrics}
summaryLoading={loading}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={fetchKeyDetail}
searchKeys={searchKeys}
teams={teamList}
hidePromptCachingMetrics={entityType === "agent"}
/>
),
},
{ key: "endpoints", label: "Endpoint Activity", content: <EndpointUsage userSpendData={spendData} /> },
];
const spendFetchState = { coversRange, cancelled, failed };
return (
<div style={{ width: "100%" }} className="relative">
<PaginationStatusAlerts
isFetchingMore={isFetchingMore}
cancelled={cancelled}
failed={failed}
progress={progress}
cancel={cancel}
/>
{showAgentBreakdown && (
<PaginationStatusAlerts
isFetchingMore={agentIsFetchingMore}
cancelled={agentCancelled}
failed={agentFailed}
progress={agentProgress}
cancel={agentCancel}
subject="agent data"
/>
{failed && (
<Alert variant="error" className="mb-2">
<AlertDescription className="text-inherit">
Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try
again.
</AlertDescription>
</Alert>
)}
{showAgentBreakdown && agentFailed && (
<Alert variant="error" className="mb-2">
<AlertDescription className="text-inherit">
Fetching agent data failed, so the totals below may be empty rather than final. Reload the page to try
again.
</AlertDescription>
</Alert>
)}
<UsageExportHeader
dateValue={dateValue}
entityType={entityType}
spendData={spendData}
onExport={(exportType, format) =>
request
? api.exportRows(request, exportType, format)
: Promise.reject(new Error("Select a date range to export"))
}
showFilters={filterSlot === undefined && entityList !== null}
filterSlot={filterSlot}
filterLabel={getFilterLabel(entityType)}
@ -706,8 +750,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
selectedFilters={selectedTags}
onFiltersChange={setSelectedTags}
filterOptions={getAllTags() || undefined}
teams={teams || []}
exportBlockedReason={getExportBlockedReason(spendFetchState)}
teams={teamList}
/>
<Tabs defaultValue={tabs[0].key}>
<TabsList className="mt-1">

View file

@ -0,0 +1,52 @@
import {
dailyActivityAggregatedCall,
dailyActivityExportCall,
dailyActivityKeyPageCall,
dailyActivityKeySearchCall,
dailyActivityModelTopKeysCall,
} from "@/components/networking";
import type { EntityType } from "@/components/EntityUsageExport/types";
import type {
DailyActivityAggregatedResponse,
DailyActivityEntity,
DailyActivityKeySearchResponse,
DailyActivityKeyPageResponse,
DailyActivityRequest,
ExportFormat,
ExportType,
ModelTopKeysResponse,
} from "@/components/UsagePage/dailyActivityApi";
export interface EntityApi {
aggregated(req: DailyActivityRequest): Promise<DailyActivityAggregatedResponse>;
keyPage(req: DailyActivityRequest, offset: number, limit: number): Promise<DailyActivityKeyPageResponse>;
searchKeys(req: DailyActivityRequest, search: string, limit?: number): Promise<DailyActivityKeySearchResponse>;
modelTopKeys(
req: DailyActivityRequest,
model: string,
byModelGroup: boolean,
limit?: number,
): Promise<ModelTopKeysResponse>;
exportRows(req: DailyActivityRequest, exportType: ExportType, format: ExportFormat): Promise<Blob>;
}
const entityApi = (
entity: DailyActivityEntity,
defaults?: Pick<DailyActivityRequest, "excludeEntityIds">,
): EntityApi => ({
aggregated: (req) => dailyActivityAggregatedCall(entity, { ...defaults, ...req }),
keyPage: (req, offset, limit) => dailyActivityKeyPageCall(entity, { ...defaults, ...req }, offset, limit),
searchKeys: (req, search, limit) => dailyActivityKeySearchCall(entity, { ...defaults, ...req }, search, limit),
modelTopKeys: (req, model, byModelGroup, limit) =>
dailyActivityModelTopKeysCall(entity, { ...defaults, ...req }, model, byModelGroup, limit),
exportRows: (req, exportType, format) => dailyActivityExportCall(entity, { ...defaults, ...req }, exportType, format),
});
export const ENTITY_API: Record<EntityType, EntityApi> = {
tag: entityApi("tag"),
team: entityApi("team", { excludeEntityIds: ["litellm-dashboard"] }),
organization: entityApi("organization"),
customer: entityApi("customer"),
agent: entityApi("agent"),
user: entityApi("user"),
};

View file

@ -25,8 +25,11 @@ beforeAll(() => {
// Mock the networking module
vi.mock("@/components/networking", () => ({
userDailyActivityCall: vi.fn(),
userDailyActivityAggregatedCall: vi.fn(),
dailyActivityAggregatedCall: vi.fn(),
dailyActivityKeyPageCall: vi.fn(),
dailyActivityKeySearchCall: vi.fn(),
dailyActivityModelTopKeysCall: vi.fn(),
dailyActivityExportCall: vi.fn(),
gatewayDailyActivityCall: vi.fn(),
tagListCall: vi.fn(),
}));
@ -66,16 +69,25 @@ vi.mock("./EndpointUsage/EndpointUsage", () => ({
vi.mock("./UsageViewSelect/UsageViewSelect", async () => {
const React = await import("react");
const UsageViewSelect = ({ value, onChange, canViewTagUsage = false }: any) => {
const UsageViewSelect = ({
value,
onChange,
canViewTagUsage = false,
}: {
value: string;
onChange?: (value: string) => void;
canViewTagUsage?: boolean;
}) => {
const tagOption = canViewTagUsage ? React.createElement("option", { value: "tag" }, "Tag Usage") : null;
const selectProps = {
value,
onChange: (e: React.ChangeEvent<HTMLSelectElement>) => onChange?.(e.target.value),
role: "combobox",
"data-testid": "usage-view-select",
};
return React.createElement(
"select",
{
value,
onChange: (e: any) => onChange?.(e.target.value),
role: "combobox",
"data-testid": "usage-view-select",
},
selectProps,
React.createElement("option", { value: "global" }, "Global Usage"),
React.createElement("option", { value: "team" }, "Team Usage"),
React.createElement("option", { value: "organization" }, "Organization Usage"),
@ -157,8 +169,9 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({
}));
describe("UsagePage", () => {
const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall);
const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall);
const mockUserDailyActivityAggregatedCall = vi.fn();
const mockDailyActivityAggregatedCall = vi.mocked(networking.dailyActivityAggregatedCall);
const mockDailyActivityKeyPageCall = vi.mocked(networking.dailyActivityKeyPageCall);
const mockTagListCall = vi.mocked(networking.tagListCall);
const mockGatewayDailyActivityCall = vi.mocked(networking.gatewayDailyActivityCall);
const mockUseCustomers = vi.mocked(useCustomers);
@ -374,7 +387,17 @@ describe("UsagePage", () => {
error: null,
} as any);
mockUserDailyActivityAggregatedCall.mockClear();
mockUserDailyActivityCall.mockClear();
mockDailyActivityAggregatedCall.mockReset();
mockDailyActivityKeyPageCall.mockReset();
mockDailyActivityKeyPageCall.mockResolvedValue({
api_keys: [],
total_api_keys: 0,
offset: 0,
limit: 50,
});
mockDailyActivityAggregatedCall.mockImplementation((entity: string, request: unknown) =>
entity === "user" ? mockUserDailyActivityAggregatedCall(request) : Promise.resolve({ results: [], metadata: {} }),
);
mockTagListCall.mockClear();
mockGatewayDailyActivityCall.mockClear();
mockUserDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
@ -462,6 +485,11 @@ describe("UsagePage", () => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2);
});
expect(screen.queryByText("75,000")).not.toBeInTheDocument();
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
expect(await screen.findByText("425,151")).toBeInTheDocument();
expect(screen.getByText("Total Tokens").closest('[data-slot="card"]')).not.toHaveTextContent(/\d/);
expect(screen.queryByText("$0.0000")).not.toBeInTheDocument();
await act(async () => {
releaseSecondFetch();
@ -469,6 +497,28 @@ describe("UsagePage", () => {
await waitFor(() => {
expect(screen.getAllByText("75,000").length).toBeGreaterThan(0);
});
expect(screen.getByText("Total Tokens")).toBeInTheDocument();
expect(screen.getByText("$0.0838")).toBeInTheDocument();
});
it("loads key pages separately from the aggregate and refreshes them when the range changes", async () => {
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
});
fireEvent.click(screen.getByText("Key Activity"));
await waitFor(() => {
expect(mockDailyActivityKeyPageCall).toHaveBeenCalledWith("user", expect.any(Object), 0, 50);
});
expect(mockUserDailyActivityAggregatedCall.mock.lastCall?.[0]).not.toHaveProperty("apiKeyLimit");
const pageCallsBeforeRangeChange = mockDailyActivityKeyPageCall.mock.calls.length;
fireEvent.click(screen.getByTestId("pick-a-different-range"));
await waitFor(() => {
expect(mockDailyActivityKeyPageCall.mock.calls.length).toBeGreaterThan(pageCallsBeforeRangeChange);
});
expect(mockUserDailyActivityAggregatedCall.mock.lastCall?.[0]).not.toHaveProperty("apiKeyLimit");
});
it("should fall back to the spend-derived count when the gateway endpoint is unavailable", async () => {
@ -793,7 +843,7 @@ describe("UsagePage", () => {
it.each(["organization", "agent"])("should not render the %s usage view for an internal user", async (usageView) => {
mockUseAuthorized.mockReturnValue(nonAdminSession);
mockDailyActivityKeyPageCall.mockImplementation(() => new Promise(() => {}));
renderWithProviders(<UsagePage {...defaultProps} organizations={mockOrganizations} />);
await waitFor(() => {
@ -924,10 +974,7 @@ describe("UsagePage", () => {
// Initially called with null (global view for admin)
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
null,
expect.objectContaining({ accessToken: "test-token", entityIds: null }),
);
});
});
@ -1016,127 +1063,23 @@ describe("UsagePage", () => {
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
"test-token",
expect.any(Date),
expect.any(Date),
"user-123",
expect.objectContaining({ accessToken: "test-token", entityIds: ["user-123"] }),
);
});
});
});
describe("aggregated endpoint fallback", () => {
it("should fall back to paginated calls when aggregated endpoint fails", async () => {
describe("aggregated endpoint failure", () => {
it("shows the failure alert instead of retrying other routes when the aggregated call fails", async () => {
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Aggregated endpoint not available"));
mockUserDailyActivityCall.mockResolvedValue({
...mockSpendData,
metadata: {
...mockSpendData.metadata,
total_pages: 1,
page: 1,
},
});
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled();
expect(mockUserDailyActivityCall).toHaveBeenCalled();
});
// Should still render the data from the paginated fallback, which lands a render after the call
expect(await screen.findByText("75,000")).toBeInTheDocument();
});
it("should stop showing the previous range's paginated pages while a new range is in flight", async () => {
// Same rule as the aggregate, one fallback further down. The flag that
// decides whether these pages are read belongs to the range the failure
// happened on, or the previous range's pages reach the tile through it.
let releaseSecondAggregated: () => void = () => {};
mockUserDailyActivityAggregatedCall.mockReset();
mockUserDailyActivityAggregatedCall
.mockRejectedValueOnce(new Error("Aggregated endpoint not available"))
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
releaseSecondAggregated = () => reject(new Error("Aggregated endpoint not available"));
}),
);
mockUserDailyActivityCall.mockResolvedValue({
...mockSpendData,
metadata: { ...mockSpendData.metadata, total_pages: 1, page: 1 },
});
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
expect(screen.getAllByText("75,000").length).toBeGreaterThan(0);
});
await act(async () => {
fireEvent.click(screen.getByTestId("pick-a-different-range"));
});
await waitFor(() => {
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledTimes(2);
});
expect(screen.queryByText("75,000")).not.toBeInTheDocument();
await act(async () => {
releaseSecondAggregated();
});
await waitFor(() => {
expect(screen.getAllByText("75,000").length).toBeGreaterThan(0);
});
});
it("should aggregate multiple pages when paginated endpoint has more than 1 page", async () => {
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("Not available"));
const page1Data = {
results: [mockSpendData.results[0]],
metadata: {
total_spend: 60,
total_api_requests: 700,
total_successful_requests: 680,
total_failed_requests: 20,
total_tokens: 35000,
total_pages: 2,
page: 1,
},
};
const page2Data = {
results: [
{
...mockSpendData.results[0],
date: "2025-01-02",
},
],
metadata: {
total_spend: 65.75,
total_api_requests: 800,
total_successful_requests: 770,
total_failed_requests: 30,
total_tokens: 40000,
total_pages: 2,
page: 2,
},
};
mockUserDailyActivityCall.mockResolvedValueOnce(page1Data).mockResolvedValueOnce(page2Data);
renderWithProviders(<UsagePage {...defaultProps} />);
await waitFor(() => {
// Both pages should have been fetched
expect(mockUserDailyActivityCall).toHaveBeenCalledTimes(2);
});
// Verify first page call
expect(mockUserDailyActivityCall).toHaveBeenCalledWith("test-token", expect.any(Date), expect.any(Date), 1, null);
// Verify second page call
expect(mockUserDailyActivityCall).toHaveBeenCalledWith("test-token", expect.any(Date), expect.any(Date), 2, null);
expect(mockDailyActivityAggregatedCall.mock.calls.filter((c) => c[0] === "user")).toHaveLength(1);
expect(await screen.findByText(/Fetching spend data failed/)).toBeInTheDocument();
});
});

View file

@ -8,15 +8,15 @@
import { ChevronDown, ChevronRight, Download, Info, Sparkles, X } from "lucide-react";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { BarChart } from "@/components/shared/charts";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts";
import { Button } from "@/components/ui/button";
import { Card as ShadcnCard, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Skeleton } from "@/components/ui/skeleton";
import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents";
import { useCustomers } from "@/app/(dashboard)/hooks/customers/useCustomers";
@ -30,23 +30,23 @@ import { ActivityMetrics, processActivityData } from "@/components/activity_metr
import CloudZeroExportModal from "@/components/cloudzero_export_modal";
import UserDropdown from "@/components/common_components/UserDropdown";
import EntityUsageExportModal from "@/components/EntityUsageExport";
import { getExportBlockedReason } from "@/components/EntityUsageExport/exportBlockedReason";
import KeyActivityPanel from "@/components/UsagePage/components/KeyActivityPanel";
import { Team } from "@/components/key_team_helpers/key_list";
import {
gatewayDailyActivityCall,
Organization,
tagListCall,
userDailyActivityAggregatedCall,
userDailyActivityCall,
} from "@/components/networking";
import { gatewayDailyActivityCall, Organization, tagListCall } from "@/components/networking";
import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
import { ChartLoader } from "@/components/shared/chart_loader";
import { Tag } from "@/components/tag_management/types";
import UserAgentActivity from "@/components/user_agent_activity";
import ViewUserSpend from "@/components/view_user_spend";
import { usePaginatedDailyActivity } from "../hooks/usePaginatedDailyActivity";
import { DailyData, MetricWithMetadata } from "@/components/UsagePage/types";
import { useAggregatedDailyActivity } from "../hooks/useAggregatedDailyActivity";
import { ENTITY_API } from "./EntityUsage/entityFetchFns";
import {
EMPTY_DAILY_ACTIVITY_METADATA,
toDailyData,
type DailyActivityRequest,
} from "@/components/UsagePage/dailyActivityApi";
import { keyDetailFromResponse, overallUsageMetrics } from "@/components/UsagePage/keyActivityData";
import { MetricWithMetadata } from "@/components/UsagePage/types";
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
import {
fetchedRangeKey,
@ -72,18 +72,11 @@ interface UsagePageProps {
organizations: Organization[];
}
const MetricValue = ({ pending, className, children }: { pending: boolean; className: string; children: ReactNode }) =>
pending ? <Skeleton className="h-8 w-24 mt-2" /> : <p className={className}>{children}</p>;
const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized();
// Aggregated endpoint: try first, fall back to paginated if unavailable
const [aggregatedData, setAggregatedData] = useState<FetchedForRange<{
results: DailyData[];
metadata: any;
}> | null>(null);
// Stamped like the data itself: the flag decides whether the paginated
// fallback is read, and a flag left over from the previous range would let
// that fallback's own leftover rows through.
const [aggregatedFailure, setAggregatedFailure] = useState<FetchedForRange<true> | null>(null);
const [aggregatedLoading, setAggregatedLoading] = useState(false);
const [gatewayActivityData, setGatewayActivityData] = useState<FetchedGatewayActivity | null>(null);
// Separate loading states for better UX
@ -142,7 +135,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
// Stamped and selected during render like the request tiles below: the tag
// filter reads "no tags" from an empty list, so a list left over from the
// previous range would state that about a range nobody has measured yet.
@ -181,30 +173,29 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
// can paint them. One source is not enough, since the tiles read the gateway
// counts, fall through to the aggregate, and fall through again to the
// paginated pages, so a stamp on any one of them is escaped by the next.
const currentAggregatedRangeKey = fetchedRangeKey(startTime, endTime, effectiveUserId);
const currentGatewayRangeKey = fetchedRangeKey(startTime, endTime);
// Try aggregated endpoint first, fall back to paginated on failure
const aggregatedFetchIdRef = useRef(0);
useEffect(() => {
if (!accessToken || !startTime || !endTime) return;
const fetchId = ++aggregatedFetchIdRef.current;
const rangeKey = currentAggregatedRangeKey;
setAggregatedLoading(true);
userDailyActivityAggregatedCall(accessToken, startTime, endTime, effectiveUserId)
.then((data) => {
if (aggregatedFetchIdRef.current !== fetchId) return;
setAggregatedData({ rangeKey, value: data });
setAggregatedLoading(false);
setIsDateChanging(false);
})
.catch(() => {
if (aggregatedFetchIdRef.current !== fetchId) return;
setAggregatedFailure({ rangeKey, value: true });
setAggregatedLoading(false);
});
}, [accessToken, startTime, endTime, effectiveUserId, currentAggregatedRangeKey]);
const dailyActivityRequest = useMemo<DailyActivityRequest | null>(
() =>
accessToken && startTime && endTime
? {
accessToken,
startTime,
endTime,
entityIds: effectiveUserId ? [effectiveUserId] : null,
}
: null,
[accessToken, startTime, endTime, effectiveUserId],
);
const {
data: aggregatedRaw,
loading: aggregatedLoading,
failed: aggregatedFailed,
} = useAggregatedDailyActivity({
fetch: () => ENTITY_API.user.aggregated(dailyActivityRequest as DailyActivityRequest),
enabled: dailyActivityRequest !== null,
deps: [accessToken, startTime, endTime, effectiveUserId],
});
// Gateway request counts (SGR). Admin-only: the source table is
// deployment-wide, so a non-admin must not see it.
@ -228,43 +219,28 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
}, [isAdmin, gatewayRequest, currentGatewayRangeKey]);
const gatewayActivity = selectGatewayActivity(isAdmin, gatewayActivityData, currentGatewayRangeKey);
const activeAggregated = selectForRange(aggregatedData, currentAggregatedRangeKey);
// A failure belongs to the range it happened on. Reading it through the same
// rule keeps the paginated hook disabled while a new range is in flight, and
// disabled is what empties it, so its previous rows never reach a tile.
const aggregatedFailed = selectForRange(aggregatedFailure, currentAggregatedRangeKey) === true;
// Paginated fallback — only enabled when aggregated endpoint fails
const paginatedResult = usePaginatedDailyActivity({
fetchFn: userDailyActivityCall,
args: [accessToken, startTime, endTime, effectiveUserId],
enabled: aggregatedFailed && !!accessToken && !!startTime && !!endTime,
});
const userSpendData = useMemo(
() => ({
results: toDailyData(aggregatedRaw),
metadata: aggregatedRaw.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA,
}),
[aggregatedRaw],
);
// Derive userSpendData from whichever source is active
const userSpendData = useMemo(() => {
if (activeAggregated) return activeAggregated;
if (aggregatedFailed) return paginatedResult.data;
return { results: [] as DailyData[], metadata: {} as any };
}, [activeAggregated, aggregatedFailed, paginatedResult.data]);
const loading = aggregatedLoading;
const requestCountsPending = loading && gatewayActivity === null;
const loading = aggregatedLoading || paginatedResult.loading;
const summaryMetrics = useMemo(
() => overallUsageMetrics(userSpendData.results, userSpendData.metadata),
[userSpendData],
);
// Read through the same range stamp as the tiles, so the export is blocked from the first
// render of a new range rather than from whenever the fetch effect gets around to running.
const spendFetchState = {
coversRange: activeAggregated !== null || paginatedResult.coversRange,
cancelled: paginatedResult.cancelled,
failed: paginatedResult.failed,
};
const exportBlockedReason = getExportBlockedReason(spendFetchState);
// Clear isDateChanging when paginated data starts arriving
useEffect(() => {
if (aggregatedFailed && !paginatedResult.loading && paginatedResult.data.results.length > 0) {
if (!loading) {
setIsDateChanging(false);
}
}, [aggregatedFailed, paginatedResult.loading, paginatedResult.data.results.length]);
}, [loading]);
// Super responsive date change handler
const handleDateChange = useCallback((newValue: DateRangePickerValue) => {
@ -432,12 +408,43 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
() => processActivityData(userSpendData, modelViewType === "groups" ? "model_groups" : "models", teams),
[userSpendData, modelViewType, teams],
);
const keyMetrics = useMemo(() => processActivityData(userSpendData, "api_keys", teams), [userSpendData, teams]);
const mcpServerMetrics = useMemo(
() => processActivityData(userSpendData, "mcp_servers", teams),
[userSpendData, teams],
);
const fetchTopApiKeys = useCallback(
(model: string) =>
ENTITY_API.user.modelTopKeys(dailyActivityRequest as DailyActivityRequest, model, modelViewType === "groups"),
[dailyActivityRequest, modelViewType],
);
const searchKeys = useCallback(
(query: string) =>
dailyActivityRequest === null
? Promise.resolve({ api_keys: [] })
: ENTITY_API.user.searchKeys(dailyActivityRequest, query),
[dailyActivityRequest],
);
const fetchKeyPage = useCallback(
(offset: number, limit: number) => {
if (dailyActivityRequest === null) {
const emptyPage = { api_keys: [], total_api_keys: 0, offset, limit };
return Promise.resolve(emptyPage);
}
return ENTITY_API.user.keyPage(dailyActivityRequest, offset, limit);
},
[dailyActivityRequest],
);
const fetchKeyDetail = useCallback(
(apiKey: string) =>
dailyActivityRequest === null
? Promise.resolve(undefined)
: ENTITY_API.user
.aggregated({ ...dailyActivityRequest, apiKey, apiKeyLimit: 1 })
.then((response) => keyDetailFromResponse(response, apiKey, teams)),
[dailyActivityRequest, teams],
);
return (
<div style={{ width: "100%" }} className="p-8 relative">
{/* Global Date Picker and Tabs - Single Row */}
@ -453,13 +460,14 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
/>
<AdvancedDatePicker value={dateValue} onValueChange={handleDateChange} />
</div>
<PaginationStatusAlerts
isFetchingMore={paginatedResult.isFetchingMore}
cancelled={paginatedResult.cancelled}
failed={paginatedResult.failed}
progress={paginatedResult.progress}
cancel={paginatedResult.cancel}
/>
{aggregatedFailed && (
<Alert variant="error" className="mb-2">
<AlertDescription className="text-inherit">
Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try
again.
</AlertDescription>
</Alert>
)}
{/* Your Usage / Global Usage Panel */}
{(usageView === "global" || usageView === "my-usage") && (
<>
@ -493,16 +501,10 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<Sparkles />
Ask AI
</Button>
<span title={exportBlockedReason}>
<Button
variant="outline"
disabled={exportBlockedReason !== undefined}
onClick={() => setIsGlobalExportModalOpen(true)}
>
<Download />
Export Data
</Button>
</span>
<Button variant="outline" onClick={() => setIsGlobalExportModalOpen(true)}>
<Download />
Export Data
</Button>
</div>
</div>
{/* Cost Panel */}
@ -532,11 +534,13 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
</p>
</div>
<ViewUserSpend
userSpend={totalSpend}
selectedTeam={null}
userMaxBudget={currentUser?.max_budget || null}
/>
{!loading && (
<ViewUserSpend
userSpend={totalSpend}
selectedTeam={null}
userMaxBudget={currentUser?.max_budget || null}
/>
)}
</div>
<div className="col-span-2">
@ -547,12 +551,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Total Requests</h3>
<p className="text-2xl font-bold mt-2">
<MetricValue pending={requestCountsPending} className="text-2xl font-bold mt-2">
{(gatewayActivity
? gatewayActivity.total_successful_requests + gatewayActivity.total_failed_requests
: userSpendData.metadata?.total_api_requests
)?.toLocaleString() || 0}
</p>
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard>
@ -577,12 +581,15 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
today: a non-admin (who may not read deployment-wide counts)
and an admin on a proxy whose table is still backfilling.
*/}
<p className="text-2xl font-bold mt-2 text-success">
<MetricValue
pending={requestCountsPending}
className="text-2xl font-bold mt-2 text-success"
>
{(
gatewayActivity?.total_successful_requests ??
userSpendData.metadata?.total_successful_requests
)?.toLocaleString() || 0}
</p>
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard>
@ -602,24 +609,27 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
</div>
{/* Same source as Successful Requests: the two must agree, or the
tile disagrees with the endpoint breakdown chart below it. */}
<p className="text-2xl font-bold mt-2 text-destructive">
<MetricValue
pending={requestCountsPending}
className="text-2xl font-bold mt-2 text-destructive"
>
{(
gatewayActivity?.total_failed_requests ??
userSpendData.metadata?.total_failed_requests
)?.toLocaleString() || 0}
</p>
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Average Cost per Request</h3>
<p className="text-2xl font-bold mt-2">
<MetricValue pending={loading} className="text-2xl font-bold mt-2">
$
{formatNumberWithCommas(
(totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1),
4,
)}
</p>
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard
@ -635,9 +645,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<ChevronRight className="size-3 text-muted-foreground" />
)}
</div>
<p className="text-2xl font-bold mt-2">
<MetricValue pending={loading} className="text-2xl font-bold mt-2">
{userSpendData.metadata?.total_tokens?.toLocaleString() || 0}
</p>
</MetricValue>
</CardContent>
</ShadcnCard>
</div>
@ -646,33 +656,33 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Input Tokens</h3>
<p className="text-2xl font-bold mt-2 text-info">
<MetricValue pending={loading} className="text-2xl font-bold mt-2 text-info">
{(userSpendData.metadata?.total_prompt_tokens || 0).toLocaleString()}
</p>
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Output Tokens</h3>
<p className="text-2xl font-bold mt-2 text-info">
<MetricValue pending={loading} className="text-2xl font-bold mt-2 text-info">
{userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0}
</p>
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Cache Read Tokens</h3>
<p className="text-2xl font-bold mt-2 text-success">
<MetricValue pending={loading} className="text-2xl font-bold mt-2 text-success">
{userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0}
</p>
</MetricValue>
</CardContent>
</ShadcnCard>
<ShadcnCard>
<CardContent>
<h3 className="text-lg font-medium text-foreground">Cache Write Tokens</h3>
<p className="text-2xl font-bold mt-2 text-purple-600">
<MetricValue pending={loading} className="text-2xl font-bold mt-2 text-purple-600">
{userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0}
</p>
</MetricValue>
</CardContent>
</ShadcnCard>
</div>
@ -858,10 +868,20 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<div className="flex justify-end mt-2 mb-4">
<ModelViewToggle value={modelViewType} onChange={setModelViewType} />
</div>
<ActivityMetrics modelMetrics={modelMetrics} />
<ActivityMetrics
modelMetrics={modelMetrics}
fetchTopApiKeys={dailyActivityRequest ? fetchTopApiKeys : undefined}
/>
</TabsContent>
<TabsContent value="keys" keepMounted>
<KeyActivityPanel keyMetrics={keyMetrics} />
<KeyActivityPanel
summary={summaryMetrics}
summaryLoading={loading}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={fetchKeyDetail}
teams={teams}
searchKeys={searchKeys}
/>
</TabsContent>
<TabsContent value="mcp" keepMounted>
<ActivityMetrics modelMetrics={mcpServerMetrics} />
@ -1004,11 +1024,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
<EntityUsageExportModal
isOpen={isGlobalExportModalOpen}
onClose={() => setIsGlobalExportModalOpen(false)}
entityType="team"
spendData={{
results: userSpendData.results,
metadata: userSpendData.metadata,
}}
entityType="user"
onExport={(exportType, format) =>
dailyActivityRequest
? ENTITY_API.user.exportRows(dailyActivityRequest, exportType, format)
: Promise.reject(new Error("Missing access token or date range"))
}
dateRange={dateValue}
selectedFilters={[]}
customTitle="Export Usage Data"

View file

@ -0,0 +1,71 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import {
EMPTY_DAILY_ACTIVITY_METADATA,
type DailyActivityAggregatedResponse,
} from "@/components/UsagePage/dailyActivityApi";
import { useAggregatedDailyActivity } from "./useAggregatedDailyActivity";
const response = (spend: number): DailyActivityAggregatedResponse => ({
results: [],
metadata: { ...EMPTY_DAILY_ACTIVITY_METADATA, total_spend: spend },
});
describe("useAggregatedDailyActivity", () => {
it("calls fetch once per deps change and exposes the data", async () => {
const fetch = vi.fn().mockResolvedValue(response(12));
const { result, rerender } = renderHook(
({ dep }: { dep: string }) => useAggregatedDailyActivity({ fetch, enabled: true, deps: [dep] }),
{ initialProps: { dep: "a" } },
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(fetch).toHaveBeenCalledTimes(1);
expect(result.current.data.metadata?.total_spend).toBe(12);
rerender({ dep: "b" });
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
});
it("does not fetch while disabled and returns empty data", () => {
const fetch = vi.fn();
const { result } = renderHook(() => useAggregatedDailyActivity({ fetch, enabled: false, deps: ["a"] }));
expect(fetch).not.toHaveBeenCalled();
expect(result.current.data.results).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.failed).toBe(false);
});
it("exposes failed on rejection", async () => {
const fetch = vi.fn().mockRejectedValue(new Error("boom"));
const { result } = renderHook(() => useAggregatedDailyActivity({ fetch, enabled: true, deps: ["a"] }));
await waitFor(() => expect(result.current.failed).toBe(true));
expect(result.current.loading).toBe(false);
});
it("ignores an out-of-order resolution from the previous deps", async () => {
let resolveFirst: ((value: DailyActivityAggregatedResponse) => void) | undefined;
const first = new Promise<DailyActivityAggregatedResponse>((resolve) => {
resolveFirst = resolve;
});
const fetch = vi
.fn()
.mockImplementationOnce(() => first)
.mockResolvedValueOnce(response(99));
const { result, rerender } = renderHook(
({ dep }: { dep: string }) => useAggregatedDailyActivity({ fetch, enabled: true, deps: [dep] }),
{ initialProps: { dep: "a" } },
);
rerender({ dep: "b" });
await act(async () => {
resolveFirst?.(response(1));
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.data.metadata?.total_spend).toBe(99);
});
});

View file

@ -0,0 +1,65 @@
import { useEffect, useRef, useState } from "react";
import {
EMPTY_DAILY_ACTIVITY_RESPONSE,
type DailyActivityAggregatedResponse,
} from "@/components/UsagePage/dailyActivityApi";
interface Options {
fetch: () => Promise<DailyActivityAggregatedResponse>;
enabled: boolean;
deps: readonly unknown[];
}
interface Result {
data: DailyActivityAggregatedResponse;
loading: boolean;
failed: boolean;
}
interface SettledFetch {
key: string;
data: DailyActivityAggregatedResponse;
failed: boolean;
}
export function useAggregatedDailyActivity({ fetch, enabled, deps }: Options): Result {
const [settled, setSettled] = useState<SettledFetch | null>(null);
const requestIdRef = useRef(0);
const fetchRef = useRef(fetch);
useEffect(() => {
fetchRef.current = fetch;
});
const depsKey = JSON.stringify(deps);
useEffect(() => {
if (!enabled) return;
const requestId = ++requestIdRef.current;
const isStale = () => requestIdRef.current !== requestId;
fetchRef
.current()
.then((response) => {
if (isStale()) return;
setSettled({ key: depsKey, data: response, failed: false });
})
.catch((error) => {
if (isStale()) return;
console.error("Error fetching daily activity:", error);
setSettled({ key: depsKey, data: EMPTY_DAILY_ACTIVITY_RESPONSE, failed: true });
});
return () => {
requestIdRef.current++;
};
}, [enabled, depsKey]);
const current = enabled && settled?.key === depsKey ? settled : null;
return {
data: current?.data ?? EMPTY_DAILY_ACTIVITY_RESPONSE,
loading: enabled && current === null,
failed: current?.failed ?? false,
};
}

View file

@ -1,322 +0,0 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import { mergeDailyResults, sumMetadata, usePaginatedDailyActivity } from "./usePaginatedDailyActivity";
describe("sumMetadata", () => {
it("sums flat cost across pages instead of keeping the first page's value", () => {
// A team whose activity spans more than one page accrues flat cost on each of them.
// Keeping page 1's value under-reports the Flat Cost and Total Cost tiles.
const merged = sumMetadata({ total_spend: 1, total_flat_cost: 174.5 }, { total_spend: 2, total_flat_cost: 777 });
expect(merged.total_flat_cost).toBe(951.5);
expect(merged.total_spend).toBe(3);
});
it("treats a page missing the field as zero rather than dropping the running total", () => {
expect(sumMetadata({ total_flat_cost: 480 }, {}).total_flat_cost).toBe(480);
expect(sumMetadata({}, { total_flat_cost: 480 }).total_flat_cost).toBe(480);
});
it("carries non-summable keys through from the first page", () => {
const merged = sumMetadata(
{ page: 1, total_pages: 3, total_spend: 1 },
{ page: 2, total_pages: 3, total_spend: 2 },
);
expect(merged.page).toBe(1);
expect(merged.total_pages).toBe(3);
});
it("sums every total_* metric the daily activity metadata exposes", () => {
// Guards the class of bug rather than one field: a new backend total that nobody adds
// to SUMMABLE_METADATA_KEYS freezes at page 1, and spend still looks right so it reads
// as trustworthy.
const page = {
total_spend: 1,
total_prompt_tokens: 1,
total_completion_tokens: 1,
total_tokens: 1,
total_api_requests: 1,
total_successful_requests: 1,
total_failed_requests: 1,
total_cache_read_input_tokens: 1,
total_cache_creation_input_tokens: 1,
total_flat_cost: 1,
total_ptu_hours: 1,
total_response_time_ms: 1,
total_timed_requests: 1,
};
const merged = sumMetadata(page, page);
for (const key of Object.keys(page)) {
expect(merged[key], `${key} must be summed across pages`).toBe(2);
}
});
});
const metricsOf = (spend: number): SpendMetrics => ({
spend,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: 1,
successful_requests: 1,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
compression_savings_spend: spend,
});
const dayOf = (date: string, spend: number, apiKey: string = "sk-1"): DailyData => ({
date,
metrics: metricsOf(spend),
breakdown: {
models: {
"gpt-4o": {
metrics: metricsOf(spend),
metadata: {},
api_key_breakdown: {
[apiKey]: { metrics: metricsOf(spend), metadata: { key_alias: "alias-1", team_id: null } },
},
},
},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: { [apiKey]: { metrics: metricsOf(spend), metadata: { key_alias: "alias-1", team_id: null } } },
entities: {},
},
});
describe("mergeDailyResults", () => {
it("collapses repeated dates into one entry with summed metrics (the LIT-5818 $2/$2/$1 case)", () => {
const merged = mergeDailyResults(mergeDailyResults([dayOf("2026-08-16", 2)], [dayOf("2026-08-16", 2)]), [
dayOf("2026-08-16", 1),
]);
expect(merged).toHaveLength(1);
expect(merged[0].metrics.spend).toBe(5);
expect(merged[0].metrics.compression_savings_spend).toBe(5);
});
it("appends unseen dates in arrival order", () => {
const merged = mergeDailyResults([dayOf("2026-08-16", 2)], [dayOf("2026-08-15", 0.5)]);
expect(merged.map((d) => d.date)).toEqual(["2026-08-16", "2026-08-15"]);
expect(merged[1].metrics.spend).toBe(0.5);
});
it("merges every breakdown level including the nested per-key breakdown", () => {
const merged = mergeDailyResults([dayOf("2026-08-16", 2, "sk-1")], [dayOf("2026-08-16", 3, "sk-1")]);
expect(merged[0].breakdown.models["gpt-4o"].metrics.spend).toBe(5);
expect(merged[0].breakdown.models["gpt-4o"].api_key_breakdown["sk-1"].metrics.spend).toBe(5);
expect(merged[0].breakdown.api_keys["sk-1"].metrics.spend).toBe(5);
expect(merged[0].breakdown.api_keys["sk-1"].metadata.key_alias).toBe("alias-1");
});
it("unions breakdown keys that appear on different pages", () => {
const merged = mergeDailyResults([dayOf("2026-08-16", 2, "sk-1")], [dayOf("2026-08-16", 3, "sk-2")]);
expect(merged[0].breakdown.api_keys["sk-1"].metrics.spend).toBe(2);
expect(merged[0].breakdown.api_keys["sk-2"].metrics.spend).toBe(3);
});
it("sums metric keys it has never heard of so a future backend column cannot silently freeze", () => {
const withExtra = (spend: number): DailyData => ({
...dayOf("2026-08-16", spend),
metrics: { ...metricsOf(spend), future_savings_spend: spend } as SpendMetrics,
});
const merged = mergeDailyResults([withExtra(2)], [withExtra(3)]);
expect((merged[0].metrics as Record<string, number>).future_savings_spend).toBe(5);
});
});
describe("usePaginatedDailyActivity page accumulation", () => {
it("returns one entry per date when a date's rows span multiple pages", async () => {
const pages = [
{ results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 1, total_spend: 2 } },
{ results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 2, total_spend: 2 } },
{
results: [dayOf("2026-08-16", 1), dayOf("2026-08-15", 0.5)],
metadata: { total_pages: 3, page: 3, total_spend: 1.5 },
},
];
const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => Promise.resolve(pages[page - 1]));
const start = new Date("2026-08-10");
const end = new Date("2026-08-17");
const { result } = renderHook(() =>
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }),
);
await waitFor(() => expect(result.current.data.metadata.page).toBe(3), { timeout: 5000 });
expect(result.current.data.results.map((d) => d.date)).toEqual(["2026-08-16", "2026-08-15"]);
expect(result.current.data.results[0].metrics.spend).toBe(5);
expect(result.current.data.metadata.total_spend).toBe(5.5);
});
});
describe("usePaginatedDailyActivity failure reporting", () => {
const firstPage = { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 3, page: 1, total_spend: 2 } };
const start = new Date("2026-08-10");
const end = new Date("2026-08-17");
it("reports a failed range so partial totals cannot pass as the whole range", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) =>
page === 1 ? Promise.resolve(firstPage) : Promise.reject(new Error("page 2 never came back")),
);
const { result } = renderHook(() =>
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }),
);
await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 });
expect(result.current.isFetchingMore).toBe(false);
expect(result.current.loading).toBe(false);
expect(result.current.data.metadata.total_spend).toBe(2);
consoleError.mockRestore();
});
it("reports no pages loaded when the very first request is what failed", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const fetchFn = vi.fn(() => Promise.reject(new Error("page 1 never came back")));
const { result } = renderHook(() =>
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }),
);
await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 });
expect(result.current.progress).toEqual({ currentPage: 0, totalPages: 0 });
consoleError.mockRestore();
});
it("stays unfailed when every page arrives", async () => {
const pages = [
firstPage,
{ results: [dayOf("2026-08-15", 1)], metadata: { total_pages: 2, page: 2, total_spend: 1 } },
];
const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) =>
Promise.resolve({ ...pages[page - 1], metadata: { ...pages[page - 1].metadata, total_pages: 2 } }),
);
const { result } = renderHook(() =>
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }),
);
await waitFor(() => expect(result.current.data.metadata.page).toBe(2), { timeout: 5000 });
expect(result.current.failed).toBe(false);
});
it("clears the failure when a new range is requested, so the banner cannot outlive it", async () => {
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
const fetchFn = vi.fn((...callArgs: unknown[]) => {
const [, , , page, filter] = callArgs as [string, Date, Date, number, string | null];
if (filter !== "broken")
return Promise.resolve({ ...firstPage, metadata: { ...firstPage.metadata, total_pages: 1 } });
if (page === 1) return Promise.resolve({ ...firstPage, metadata: { ...firstPage.metadata, total_pages: 2 } });
return Promise.reject(new Error("page 2 never came back"));
});
const { result, rerender } = renderHook(
({ filter }: { filter: string | null }) =>
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, filter], enabled: true }),
{ initialProps: { filter: "broken" as string | null } },
);
await waitFor(() => expect(result.current.failed).toBe(true), { timeout: 5000 });
rerender({ filter: "healthy" });
await waitFor(() => expect(result.current.failed).toBe(false), { timeout: 5000 });
consoleError.mockRestore();
});
});
describe("usePaginatedDailyActivity range coverage", () => {
const start = new Date("2026-08-10");
const end = new Date("2026-08-17");
const singlePage = { results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 1, page: 1, total_spend: 2 } };
it("does not cover the range while the hook is disabled", () => {
const fetchFn = vi.fn(() => Promise.resolve(singlePage));
const { result } = renderHook(() =>
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: false }),
);
expect(result.current.coversRange).toBe(false);
expect(fetchFn).not.toHaveBeenCalled();
});
it("covers the range only once every page of it has landed", async () => {
const pages = [
{ results: [dayOf("2026-08-16", 2)], metadata: { total_pages: 2, page: 1, total_spend: 2 } },
{ results: [dayOf("2026-08-15", 1)], metadata: { total_pages: 2, page: 2, total_spend: 1 } },
];
const fetchFn = vi.fn((_token: string, _start: Date, _end: Date, page: number) => Promise.resolve(pages[page - 1]));
const { result } = renderHook(() =>
usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled: true }),
);
expect(result.current.coversRange).toBe(false);
await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 });
});
it("never reports a range as covered while the data on screen is empty", async () => {
// Disabling the hook empties the data. Re-enabling it asks for the same args the last
// completed fetch used, so coverage that survives the disable would vouch for nothing.
const seen: Array<{ coversRange: boolean; rows: number }> = [];
const fetchFn = vi.fn(() => Promise.resolve(singlePage));
const { result, rerender } = renderHook(
({ enabled }: { enabled: boolean }) => {
const activity = usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, null], enabled });
seen.push({ coversRange: activity.coversRange, rows: activity.data.results.length });
return activity;
},
{ initialProps: { enabled: true } },
);
await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 });
rerender({ enabled: false });
rerender({ enabled: true });
await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 });
expect(seen.filter((render) => render.coversRange && render.rows === 0)).toEqual([]);
});
it("stops covering the range on the very render the args change, not once an effect catches up", async () => {
// The render after a filter change still holds the previous filter's rows, so resetting
// coverage inside the fetch effect would leave a paint where the export reads them as the
// new range. That paint is the whole thing the gate exists to stop.
const seen: Array<{ filter: string; coversRange: boolean }> = [];
const fetchFn = vi.fn(() => Promise.resolve(singlePage));
const { result, rerender } = renderHook(
({ filter }: { filter: string }) => {
const activity = usePaginatedDailyActivity({ fetchFn, args: ["tok", start, end, filter], enabled: true });
seen.push({ filter, coversRange: activity.coversRange });
return activity;
},
{ initialProps: { filter: "team-a" } },
);
await waitFor(() => expect(result.current.coversRange).toBe(true), { timeout: 5000 });
rerender({ filter: "team-b" });
const rendersForNewFilter = seen.filter((render) => render.filter === "team-b");
expect(rendersForNewFilter.length).toBeGreaterThan(0);
expect(rendersForNewFilter.map((render) => render.coversRange)).not.toContain(true);
});
});

View file

@ -1,375 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
BreakdownMetrics,
DailyData,
KeyMetricWithMetadata,
MetricWithMetadata,
SpendMetrics,
} from "@/components/UsagePage/types";
export interface PaginationProgress {
currentPage: number;
totalPages: number;
}
/** Delay between sequential page fetches (ms) to avoid overloading the backend. */
const PAGE_FETCH_DELAY_MS = 300;
/** Number of pages to accumulate before flushing to React state (reduces re-renders). */
const RENDER_BATCH_SIZE = 3;
/** The metadata fields returned by the daily activity API that should be summed across pages. */
const SUMMABLE_METADATA_KEYS = [
"total_spend",
"total_prompt_tokens",
"total_completion_tokens",
"total_tokens",
"total_api_requests",
"total_successful_requests",
"total_failed_requests",
"total_cache_read_input_tokens",
"total_cache_creation_input_tokens",
"total_flat_cost",
"total_ptu_hours",
"total_response_time_ms",
"total_timed_requests",
] as const;
interface DailyActivityResponse {
results: DailyData[];
metadata: Record<string, any>;
}
type FetchPageFn = (...args: any[]) => Promise<DailyActivityResponse>;
interface UsePaginatedDailyActivityParams {
/** The API call function (e.g., userDailyActivityCall). */
fetchFn: FetchPageFn;
/** Arguments to pass to fetchFn: [accessToken, startTime, endTime, ...extraArgs]. Page is injected by the hook at index 3. */
args: any[];
/** Whether the hook should fetch. Set to false to disable. */
enabled: boolean;
/**
* Optional single-shot endpoint returning the whole range at once (e.g.
* teamDailyActivityAggregatedCall). Called with `args` as-is (no page).
* On success pagination is skipped entirely; on failure the hook falls
* back to the paginated flow.
*/
aggregatedFetchFn?: (...args: any[]) => Promise<DailyActivityResponse>;
}
interface UsePaginatedDailyActivityReturn {
data: DailyActivityResponse;
loading: boolean;
isFetchingMore: boolean;
progress: PaginationProgress;
cancelled: boolean;
failed: boolean;
coversRange: boolean;
cancel: () => void;
}
const EMPTY_DATA: DailyActivityResponse = {
results: [],
metadata: {
total_spend: 0,
total_prompt_tokens: 0,
total_completion_tokens: 0,
total_tokens: 0,
total_api_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,
total_cache_read_input_tokens: 0,
total_cache_creation_input_tokens: 0,
total_response_time_ms: 0,
total_timed_requests: 0,
total_pages: 1,
has_more: false,
page: 1,
},
};
/**
* Combine two pages of metadata. Only keys in SUMMABLE_METADATA_KEYS are added; anything
* else keeps the first page's value, so a total the backend adds later is silently frozen
* at page 1 until it is listed above. Exported so that contract can be tested directly.
*/
export function sumMetadata(a: Record<string, any>, b: Record<string, any>): Record<string, any> {
const result = { ...a };
for (const key of SUMMABLE_METADATA_KEYS) {
result[key] = (a[key] || 0) + (b[key] || 0);
}
return result;
}
/**
* Sum the union of numeric metric keys so a metric column added to the backend
* later is summed automatically instead of silently frozen at one page's value
* (the drift hazard SUMMABLE_METADATA_KEYS documents above).
*/
const addMetrics = (a: SpendMetrics, b: SpendMetrics): SpendMetrics =>
Object.fromEntries(
Array.from(new Set([...Object.keys(a), ...Object.keys(b)])).map((key) => {
const left = a[key as keyof SpendMetrics];
const right = b[key as keyof SpendMetrics];
if (typeof left !== "number" && typeof right !== "number") return [key, left ?? right];
return [key, (typeof left === "number" ? left : 0) + (typeof right === "number" ? right : 0)];
}),
) as unknown as SpendMetrics;
const mergeBucketMaps = <T>(
a: Record<string, T> | undefined,
b: Record<string, T> | undefined,
mergeEntry: (left: T, right: T) => T,
): Record<string, T> => {
const left = a ?? {};
const right = b ?? {};
return Object.fromEntries(
Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).map((key) => {
const leftEntry = left[key];
const rightEntry = right[key];
if (leftEntry === undefined) return [key, rightEntry];
if (rightEntry === undefined) return [key, leftEntry];
return [key, mergeEntry(leftEntry, rightEntry)];
}),
);
};
const mergeKeyMetric = (a: KeyMetricWithMetadata, b: KeyMetricWithMetadata): KeyMetricWithMetadata => ({
...a,
metrics: addMetrics(a.metrics, b.metrics),
});
const mergeMetricWithMetadata = (a: MetricWithMetadata, b: MetricWithMetadata): MetricWithMetadata => ({
...a,
metrics: addMetrics(a.metrics, b.metrics),
api_key_breakdown: mergeBucketMaps(a.api_key_breakdown, b.api_key_breakdown, mergeKeyMetric),
});
const mergeBreakdown = (a: BreakdownMetrics, b: BreakdownMetrics): BreakdownMetrics => ({
models: mergeBucketMaps(a.models, b.models, mergeMetricWithMetadata),
model_groups: mergeBucketMaps(a.model_groups, b.model_groups, mergeMetricWithMetadata),
mcp_servers: mergeBucketMaps(a.mcp_servers, b.mcp_servers, mergeMetricWithMetadata),
providers: mergeBucketMaps(a.providers, b.providers, mergeMetricWithMetadata),
api_keys: mergeBucketMaps(a.api_keys, b.api_keys, mergeKeyMetric),
entities: mergeBucketMaps(a.entities, b.entities, mergeMetricWithMetadata),
...(a.endpoints || b.endpoints
? { endpoints: mergeBucketMaps(a.endpoints, b.endpoints, mergeMetricWithMetadata) }
: {}),
});
/**
* The backend paginates over raw rows and re-groups per page, so a date whose
* rows span pages arrives as one partial DailyData per page. Merge by date so
* consumers never see the same date twice (LIT-5818: each day rendered as N
* partial bars). Exported so the contract can be tested directly.
*/
export function mergeDailyResults(existing: readonly DailyData[], incoming: readonly DailyData[]): DailyData[] {
return incoming.reduce<DailyData[]>(
(acc, day) => {
const index = acc.findIndex((existingDay) => existingDay.date === day.date);
if (index === -1) return [...acc, day];
return acc.map((existingDay, i) =>
i === index
? {
...existingDay,
metrics: addMetrics(existingDay.metrics, day.metrics),
breakdown: mergeBreakdown(existingDay.breakdown, day.breakdown),
}
: existingDay,
);
},
[...existing],
);
}
/**
* Hook that auto-paginates daily activity endpoints, updating state in batches
* so charts render progressively. Cancels on unmount, param changes, or
* manual cancel().
*
* The `args` array should contain every argument the fetchFn expects EXCEPT
* the `page` parameter. The hook injects `page` as the 4th argument (index 3),
* matching the signature of all daily activity calls:
* (accessToken, startTime, endTime, page, ...rest)
*/
export function usePaginatedDailyActivity({
fetchFn,
args,
enabled,
aggregatedFetchFn,
}: UsePaginatedDailyActivityParams): UsePaginatedDailyActivityReturn {
const [data, setData] = useState<DailyActivityResponse>(EMPTY_DATA);
const [loading, setLoading] = useState(false);
const [isFetchingMore, setIsFetchingMore] = useState(false);
const [progress, setProgress] = useState<PaginationProgress>({
currentPage: 0,
totalPages: 0,
});
const [cancelled, setCancelled] = useState(false);
const [failed, setFailed] = useState(false);
const [completedKey, setCompletedKey] = useState<string | null>(null);
const fetchIdRef = useRef(0);
const cancelledRef = useRef(false);
const delayTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep args in a ref so the effect can always read the latest values
// without needing them in the dependency array.
const argsRef = useRef(args);
argsRef.current = args;
// Stable serialised key so the effect only re-runs when the arg *values* change.
const argsKey = JSON.stringify(args);
// Stamped like the data itself and compared during render, so the render that follows an arg
// change already reports the new range as uncovered. Clearing it inside the fetch effect would
// be one render too late, leaving a paint where an export reads the previous range's rows.
const coversRange = enabled && completedKey === argsKey;
const cancel = useCallback(() => {
cancelledRef.current = true;
setCancelled(true);
setIsFetchingMore(false);
if (delayTimerRef.current !== null) {
clearTimeout(delayTimerRef.current);
delayTimerRef.current = null;
}
}, []);
useEffect(() => {
if (!enabled) {
setData(EMPTY_DATA);
setLoading(false);
setIsFetchingMore(false);
setProgress({ currentPage: 0, totalPages: 0 });
setCancelled(false);
setFailed(false);
setCompletedKey(null);
return;
}
const currentFetchId = ++fetchIdRef.current;
cancelledRef.current = false;
setCancelled(false);
setFailed(false);
const isStale = () => fetchIdRef.current !== currentFetchId || cancelledRef.current;
/** Cancellable delay that clears itself on cleanup. */
const delay = (ms: number) =>
new Promise<void>((resolve) => {
delayTimerRef.current = setTimeout(() => {
delayTimerRef.current = null;
resolve();
}, ms);
});
const run = async () => {
const currentArgs = argsRef.current;
setLoading(true);
setIsFetchingMore(false);
setProgress({ currentPage: 0, totalPages: 0 });
if (aggregatedFetchFn) {
try {
const aggregated = await aggregatedFetchFn(...currentArgs);
if (isStale()) return;
setData(aggregated);
setProgress({ currentPage: 1, totalPages: 1 });
setLoading(false);
setCompletedKey(argsKey);
return;
} catch (error) {
if (isStale()) return;
console.error("Aggregated daily activity failed, falling back to pagination:", error);
}
}
try {
// Inject page=1 as the 4th argument.
const argsWithPage = [...currentArgs.slice(0, 3), 1, ...currentArgs.slice(3)];
const firstPage = await fetchFn(...argsWithPage);
if (isStale()) return;
setData(firstPage);
const totalPages = firstPage.metadata?.total_pages || 1;
setProgress({ currentPage: 1, totalPages });
if (totalPages <= 1) {
setLoading(false);
setCompletedKey(argsKey);
return;
}
// More pages — start fetching sequentially.
setLoading(false);
setIsFetchingMore(true);
let accumulatedResults = mergeDailyResults([], firstPage.results);
let accumulatedMetadata = { ...firstPage.metadata };
for (let page = 2; page <= totalPages; page++) {
if (isStale()) return;
// Small delay to avoid overwhelming the backend.
await delay(PAGE_FETCH_DELAY_MS);
if (isStale()) return;
const argsForPage = [...currentArgs.slice(0, 3), page, ...currentArgs.slice(3)];
const pageData = await fetchFn(...argsForPage);
if (isStale()) return;
accumulatedResults = mergeDailyResults(accumulatedResults, pageData.results);
accumulatedMetadata = sumMetadata(accumulatedMetadata, pageData.metadata);
accumulatedMetadata.total_pages = totalPages;
accumulatedMetadata.has_more = page < totalPages;
accumulatedMetadata.page = page;
// Flush accumulated data and progress to React state every
// RENDER_BATCH_SIZE pages (or on the final page) to avoid
// expensive per-page re-renders. Progress and data are updated
// together so the counter never appears to decrement.
const isLastPage = page === totalPages;
const isBatchBoundary = (page - 1) % RENDER_BATCH_SIZE === 0;
if (isLastPage || isBatchBoundary) {
setData({
results: accumulatedResults,
metadata: accumulatedMetadata,
});
setProgress({ currentPage: page, totalPages });
}
}
setIsFetchingMore(false);
setCompletedKey(argsKey);
} catch (error) {
if (!isStale()) {
console.error("Error fetching daily activity:", error);
setLoading(false);
setIsFetchingMore(false);
setFailed(true);
}
}
};
run();
return () => {
fetchIdRef.current++;
if (delayTimerRef.current !== null) {
clearTimeout(delayTimerRef.current);
delayTimerRef.current = null;
}
};
// argsKey is a stable JSON string so the effect only re-fires when arg values change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, fetchFn, aggregatedFetchFn, argsKey]);
return { data, loading, isFetchingMore, progress, cancelled, failed, coversRange, cancel };
}

View file

@ -21,7 +21,6 @@ const mockTeamInfoCall = vi.fn();
const mockUserUpdateUserCall = vi.fn();
const mockFetchMCPServers = vi.fn();
const mockListMCPTools = vi.fn();
const mockUserDailyActivityCall = vi.fn();
const mockUserDailyActivityAggregatedCall = vi.fn();
const MCP_SERVER = { server_id: "srv-1", server_name: "GitHub MCP", alias: "GitHub MCP" };
@ -65,8 +64,7 @@ vi.mock("@/components/networking", async (importOriginal) => {
formatDate: original.formatDate,
serverRootPath: "/",
userGetInfoV2: (...args: unknown[]) => mockUserGetInfoV2(...args),
userDailyActivityCall: (...args: unknown[]) => mockUserDailyActivityCall(...args),
userDailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args),
dailyActivityAggregatedCall: (...args: unknown[]) => mockUserDailyActivityAggregatedCall(...args),
userDeleteCall: vi.fn(),
userUpdateUserCall: (...args: unknown[]) => mockUserUpdateUserCall(...args),
modelAvailableCall: vi.fn().mockResolvedValue({ data: [] }),
@ -458,7 +456,6 @@ describe("UserInfoView savings", () => {
Promise.resolve({ ...MOCK_USER_DATA_NO_TEAMS, user_id: userId }),
);
mockUserDailyActivityAggregatedCall.mockReset().mockResolvedValue(savingsResponse([]));
mockUserDailyActivityCall.mockReset().mockResolvedValue(savingsResponse([]));
});
afterEach(() => {
@ -472,17 +469,18 @@ describe("UserInfoView savings", () => {
const { rerender } = render(<UserInfoView {...props} userId="user-1" userRole={userRole} />);
await user.click(await screen.findByRole("tab", { name: "Savings" }));
expect(await screen.findByText("No usage recorded for this user in this range.")).toBeInTheDocument();
expect(mockUserDailyActivityAggregatedCall.mock.calls[0][3]).toBe("user-1");
expect(mockUserDailyActivityAggregatedCall.mock.calls[0]).toEqual([
"user",
expect.objectContaining({ entityIds: ["user-1"] }),
]);
mockUserDailyActivityAggregatedCall.mockClear();
mockUserDailyActivityCall.mockClear();
rerender(<UserInfoView {...props} userId="another-user" userRole={userRole} />);
await screen.findAllByText("another-user");
expect(screen.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
expect(screen.queryByRole("tab", { name: "Savings" })).not.toBeInTheDocument();
expect(screen.queryByText("No usage recorded for this user in this range.")).not.toBeInTheDocument();
expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled();
expect(mockUserDailyActivityCall).not.toHaveBeenCalled();
},
);
@ -506,7 +504,6 @@ describe("UserInfoView savings", () => {
render(<UserInfoView {...props} />);
const savingsTab = await screen.findByRole("tab", { name: "Savings" });
expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled();
expect(mockUserDailyActivityCall).not.toHaveBeenCalled();
await user.click(savingsTab);
@ -516,12 +513,12 @@ describe("UserInfoView savings", () => {
expect(screen.getByTestId("summary-card-prompt-caching-savings")).toHaveTextContent("$1.00Total");
expect(screen.getByTestId("summary-card-auto-router-savings")).toHaveTextContent("-$3.00");
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledExactlyOnceWith(
"admin-token",
expect.any(Date),
expect.any(Date),
"user-123",
true,
null,
"user",
expect.objectContaining({
accessToken: "admin-token",
entityIds: ["user-123"],
includeCurrentUtcDay: true,
}),
);
expect(screen.getByTestId("user-savings-scope-note")).toHaveTextContent("JWT-authenticated requests");
await user.click(screen.getByRole("tab", { name: "Per day" }));
@ -544,12 +541,12 @@ describe("UserInfoView savings", () => {
expect(await screen.findByTestId("user-savings-empty")).toHaveTextContent("Loading savings");
expect(screen.queryByTestId("summary-card-total-recorded-savings")).not.toBeInTheDocument();
expect(mockUserDailyActivityAggregatedCall).toHaveBeenLastCalledWith(
"admin-token",
expect.any(Date),
expect.any(Date),
"user-456",
true,
null,
"user",
expect.objectContaining({
accessToken: "admin-token",
entityIds: ["user-456"],
includeCurrentUtcDay: true,
}),
);
await act(async () => {
nextUser.resolve(savingsResponse([savingsDay("2026-09-19", { autorouter_savings_spend: -7 })]));
@ -596,27 +593,16 @@ describe("UserInfoView savings", () => {
expect(await screen.findByTestId("summary-card-total-recorded-savings")).toHaveTextContent("-$7.00");
});
it("reports an incomplete paginated read as unavailable instead of displaying a partial savings total", async () => {
it("reports a failed read as unavailable instead of displaying a partial savings total", async () => {
mockUserDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable"));
mockUserDailyActivityCall
.mockResolvedValueOnce({
results: [savingsDay("2026-09-19", { compression_savings_spend: 42 })],
metadata: { total_pages: 2, has_more: true, page: 1 },
})
.mockRejectedValueOnce(new Error("next page unavailable"));
const user = userEvent.setup();
render(<UserInfoView {...props} />);
await user.click(await screen.findByRole("tab", { name: "Savings" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Savings are unavailable for this range");
expect(mockUserDailyActivityCall).toHaveBeenLastCalledWith(
"admin-token",
expect.any(Date),
expect.any(Date),
2,
"user-123",
true,
null,
expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalledWith(
"user",
expect.objectContaining({ entityIds: ["user-123"] }),
);
expect(screen.queryByTestId("summary-card-total-recorded-savings")).not.toBeInTheDocument();
expect(screen.queryByText(/No usage recorded/)).not.toBeInTheDocument();
@ -639,6 +625,5 @@ describe("UserInfoView savings", () => {
expect(screen.getByRole("alert")).toHaveTextContent("this user has no ID");
expect(mockUserDailyActivityAggregatedCall).not.toHaveBeenCalled();
expect(mockUserDailyActivityCall).not.toHaveBeenCalled();
});
});

View file

@ -1,61 +1,22 @@
/**
* Tests for EntityUsageExportModal component
*
* Validates core export functionality:
* - Renders modal with correct default state (CSV format, daily scope)
* - User can select export type (daily vs daily_with_models)
* - User can switch format (CSV vs JSON)
* - Export button triggers data generation with correct parameters
* - Modal closes after successful export
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen } from "@testing-library/react";
import { renderWithProviders } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import EntityUsageExportModal from "./EntityUsageExportModal";
// Mock utilities that format/export data so tests stay fast and deterministic
vi.mock("./utils", () => {
return {
handleExportCSV: vi.fn(),
handleExportJSON: vi.fn(),
generateExportData: vi.fn(() => [{ Date: "2025-10-01" }]),
generateMetadata: vi.fn(() => ({ meta: true })),
};
});
const downloadBlob = vi.fn();
// Mock useTeams hook
vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({
useTeams: vi.fn(() => ({
data: [],
isLoading: false,
error: null,
refetch: vi.fn(),
})),
vi.mock("./utils", () => ({
downloadBlob: (...args: unknown[]) => downloadBlob(...args),
exportFilename: vi.fn(() => "tag_usage_daily_2025-10-01_2025-10-14.csv"),
}));
// JSDOM stubs for download flow used by the modal
// @ts-ignore
global.URL.createObjectURL = vi.fn(() => "blob:mock");
// @ts-ignore
global.URL.revokeObjectURL = vi.fn();
describe("EntityUsageExportModal", () => {
const baseProps = {
isOpen: true,
onClose: vi.fn(),
entityType: "tag" as const,
spendData: {
results: [],
metadata: {
total_spend: 0,
total_api_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,
total_tokens: 0,
},
},
onExport: vi.fn().mockResolvedValue(new Blob(["data"])),
dateRange: { from: new Date("2025-10-01"), to: new Date("2025-10-14") },
selectedFilters: [],
customTitle: "Export Tag Usage",
@ -63,55 +24,44 @@ describe("EntityUsageExportModal", () => {
beforeEach(() => {
vi.clearAllMocks();
baseProps.onExport.mockResolvedValue(new Blob(["data"]));
});
it("renders default state and exports CSV (daily) successfully", async () => {
/**
* Tests the happy path: user opens modal and exports with defaults.
* Verifies that handleExportCSV is called with correct parameters
* and modal closes after export completes.
*/
it("exports through the onExport callback with the selected type and format, then closes", async () => {
const user = userEvent.setup();
const { handleExportCSV } = await import("./utils");
renderWithProviders(<EntityUsageExportModal {...baseProps} />);
// Default primary action reflects CSV export
expect(screen.getByRole("button", { name: /Export CSV/i })).toBeInTheDocument();
// Click export
await user.click(screen.getByRole("button", { name: /Export CSV/i }));
// Verifies export function was invoked with correct parameters
expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily", "Tag", "tag", {});
// Modal closes after export
expect(baseProps.onExport).toHaveBeenCalledWith("daily", "csv");
expect(downloadBlob).toHaveBeenCalledWith(expect.any(Blob), "tag_usage_daily_2025-10-01_2025-10-14.csv");
expect(baseProps.onClose).toHaveBeenCalled();
});
it("exports with 'day-by-day by tag and model' scope when selected", async () => {
/**
* Tests that user can change export type (scope).
* Verifies handleExportCSV receives 'daily_with_models' scope
* when the second radio option is selected.
*/
it("forwards the export type the user picks", async () => {
const user = userEvent.setup();
const { handleExportCSV } = await import("./utils");
renderWithProviders(<EntityUsageExportModal {...baseProps} />);
// Choose the alternate export type - click the label to trigger radio
const dailyModelLabel = screen.getByText(/Day-by-day by tag and model/i);
await user.click(dailyModelLabel);
await user.click(screen.getByRole("radio", { name: /Day-by-day breakdown by tag and key/i }));
await user.click(screen.getByRole("button", { name: /Export CSV/i }));
// Export with default CSV format
const exportBtn = screen.getByRole("button", { name: /Export CSV/i });
await user.click(exportBtn);
// Ensure the selected scope flowed through
expect(handleExportCSV).toHaveBeenCalledWith(baseProps.spendData, "daily_with_models", "Tag", "tag", {});
// Modal closes after export
expect(baseProps.onExport).toHaveBeenCalledWith("daily_with_keys", "csv");
expect(baseProps.onClose).toHaveBeenCalled();
});
it("keeps the modal open and reports the error when onExport rejects", async () => {
const user = userEvent.setup();
baseProps.onExport.mockRejectedValueOnce(new Error("export failed"));
renderWithProviders(<EntityUsageExportModal {...baseProps} />);
await user.click(screen.getByRole("button", { name: /Export CSV/i }));
expect(baseProps.onExport).toHaveBeenCalled();
expect(downloadBlob).not.toHaveBeenCalled();
expect(baseProps.onClose).not.toHaveBeenCalled();
});
});

View file

@ -1,47 +1,36 @@
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { createTeamAliasMap } from "@/utils/teamUtils";
import { Loader2 } from "lucide-react";
import React, { useMemo, useState } from "react";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "@/lib/toast";
import ExportFormatSelector from "./ExportFormatSelector";
import ExportSummary from "./ExportSummary";
import ExportTypeSelector from "./ExportTypeSelector";
import type { EntityUsageExportModalProps, ExportFormat, ExportScope } from "./types";
import { handleExportCSV, handleExportJSON } from "./utils";
import type { EntityUsageExportModalProps, ExportFormat, ExportType } from "./types";
import { downloadBlob, exportFilename } from "./utils";
const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
isOpen,
onClose,
entityType,
spendData,
onExport,
dateRange,
selectedFilters,
selectedFilters = [],
customTitle,
}) => {
const [exportFormat, setExportFormat] = useState<ExportFormat>("csv");
const [exportScope, setExportScope] = useState<ExportScope>("daily");
const [exportType, setExportType] = useState<ExportType>("daily");
const [isExporting, setIsExporting] = useState(false);
const { data: teams, isLoading: isLoadingTeams } = useTeams();
const entityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1);
const modalTitle = customTitle || `Export ${entityLabel} Usage`;
// Cache team alias map using useMemo
const teamAliasMap = useMemo(() => createTeamAliasMap(teams), [teams]);
const handleExport = async (format?: ExportFormat) => {
const formatToUse = format || exportFormat;
const handleExport = async () => {
setIsExporting(true);
try {
if (formatToUse === "csv") {
handleExportCSV(spendData, exportScope, entityLabel, entityType, teamAliasMap);
toast.success(`${entityLabel} usage data exported successfully as CSV`);
} else {
handleExportJSON(spendData, exportScope, entityLabel, entityType, dateRange, selectedFilters, teamAliasMap);
toast.success(`${entityLabel} usage data exported successfully as JSON`);
}
const blob = await onExport(exportType, exportFormat);
downloadBlob(blob, exportFilename(entityType, exportType, exportFormat, dateRange));
toast.success(`${entityLabel} usage data exported successfully as ${exportFormat.toUpperCase()}`);
onClose();
} catch (error) {
console.error("Error exporting data:", error);
@ -63,36 +52,17 @@ const EntityUsageExportModal: React.FC<EntityUsageExportModalProps> = ({
<DialogTitle className="text-base font-semibold">{modalTitle}</DialogTitle>
</DialogHeader>
<div className="space-y-5 py-2">
{isLoadingTeams ? (
<div className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
) : (
<>
<ExportSummary dateRange={dateRange} selectedFilters={selectedFilters} />
<ExportTypeSelector value={exportScope} onChange={setExportScope} entityType={entityType} />
<ExportFormatSelector value={exportFormat} onChange={setExportFormat} />
</>
)}
<ExportSummary dateRange={dateRange} selectedFilters={selectedFilters} />
<ExportTypeSelector value={exportType} onChange={setExportType} entityType={entityType} />
<ExportFormatSelector value={exportFormat} onChange={setExportFormat} />
<div className="flex items-center justify-end gap-2 pt-4 border-t">
{isLoadingTeams ? (
<>
<Skeleton className="h-9 w-20" />
<Skeleton className="h-9 w-28" />
</>
) : (
<>
<Button variant="outline" onClick={onClose} disabled={isExporting}>
Cancel
</Button>
<Button onClick={() => handleExport()} disabled={isExporting}>
{isExporting && <Loader2 className="animate-spin" />}
{isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
</Button>
</>
)}
<Button variant="outline" onClick={onClose} disabled={isExporting}>
Cancel
</Button>
<Button onClick={handleExport} disabled={isExporting}>
{isExporting && <Loader2 className="animate-spin" />}
{isExporting ? "Exporting..." : `Export ${exportFormat.toUpperCase()}`}
</Button>
</div>
</div>
</DialogContent>

View file

@ -1,6 +1,6 @@
import { renderWithProviders, screen } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import ExportTypeSelector from "./ExportTypeSelector";
describe("ExportTypeSelector", () => {

View file

@ -1,15 +1,15 @@
import React from "react";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import type { ExportScope, EntityType } from "./types";
import type { ExportType, EntityType } from "./types";
interface ExportTypeSelectorProps {
value: ExportScope;
onChange: (value: ExportScope) => void;
value: ExportType;
onChange: (value: ExportType) => void;
entityType: EntityType;
}
const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange, entityType }) => {
const allScopes: { value: ExportScope; title: string; description: string }[] = [
const allScopes: { value: ExportType; title: string; description: string }[] = [
{
value: "daily",
title: `Day-by-day breakdown by ${entityType}`,
@ -36,7 +36,7 @@ const ExportTypeSelector: React.FC<ExportTypeSelectorProps> = ({ value, onChange
return (
<div>
<label className="text-sm font-medium text-foreground block mb-2">Export type</label>
<RadioGroup value={value} onValueChange={(next) => onChange(next as ExportScope)} className="gap-2">
<RadioGroup value={value} onValueChange={(next) => onChange(next as ExportType)} className="gap-2">
{scopes.map((scope) => (
<label
key={scope.value}

View file

@ -2,7 +2,6 @@ import { renderWithProviders, screen } from "../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import UsageExportHeader from "./UsageExportHeader";
import type { EntitySpendData } from "./types";
vi.mock("./EntityUsageExportModal", () => ({
default: ({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) =>
@ -16,16 +15,7 @@ vi.mock("./EntityUsageExportModal", () => ({
const defaultProps = {
dateValue: { from: new Date("2025-01-01"), to: new Date("2025-01-31") },
entityType: "team" as const,
spendData: {
results: [],
metadata: {
total_spend: 0,
total_api_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,
total_tokens: 0,
},
} satisfies EntitySpendData,
onExport: vi.fn().mockResolvedValue(new Blob(["data"])),
};
describe("UsageExportHeader", () => {
@ -41,27 +31,6 @@ describe("UsageExportHeader", () => {
expect(screen.getByTestId("export-modal")).toBeInTheDocument();
});
it("blocks the export while the data on screen does not cover the range", async () => {
const user = userEvent.setup();
renderWithProviders(
<UsageExportHeader
{...defaultProps}
exportBlockedReason="Spend data is still loading, so an export would under-report. Wait for it to finish."
/>,
);
const exportButton = screen.getByRole("button", { name: /export data/i });
expect(exportButton).toBeDisabled();
await user.click(exportButton);
expect(screen.queryByTestId("export-modal")).not.toBeInTheDocument();
});
it("explains why the export is blocked on hover", () => {
renderWithProviders(<UsageExportHeader {...defaultProps} exportBlockedReason="Spend data is still loading" />);
expect(screen.getByTitle("Spend data is still loading")).toBeInTheDocument();
});
it("should close the export modal when onClose is called", async () => {
const user = userEvent.setup();
renderWithProviders(<UsageExportHeader {...defaultProps} />);

View file

@ -16,13 +16,13 @@ import {
useComboboxAnchor,
} from "@/components/ui/combobox";
import EntityUsageExportModal from "./EntityUsageExportModal";
import type { EntitySpendData, EntityType } from "./types";
import type { EntityType, ExportFormat, ExportType } from "./types";
import type { Team } from "@/components/key_team_helpers/key_list";
interface UsageExportHeaderProps {
dateValue: DateRangePickerValue;
entityType: EntityType;
spendData: EntitySpendData;
onExport: (exportType: ExportType, format: ExportFormat) => Promise<Blob>;
// Optional filter props
showFilters?: boolean;
filterLabel?: string;
@ -34,13 +34,12 @@ interface UsageExportHeaderProps {
customTitle?: string;
compactLayout?: boolean;
teams?: Team[];
exportBlockedReason?: string;
}
const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
dateValue,
entityType,
spendData,
onExport,
showFilters = false,
filterLabel,
filterPlaceholder,
@ -51,7 +50,6 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
customTitle,
compactLayout = false,
teams = [],
exportBlockedReason,
}) => {
const anchor = useComboboxAnchor();
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
@ -123,12 +121,10 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
)}
<div className="justify-self-end">
<span title={exportBlockedReason}>
<Button disabled={exportBlockedReason !== undefined} onClick={() => setIsExportModalOpen(true)}>
<Download />
Export Data
</Button>
</span>
<Button onClick={() => setIsExportModalOpen(true)}>
<Download />
Export Data
</Button>
</div>
</div>
</div>
@ -137,7 +133,7 @@ const UsageExportHeader: React.FC<UsageExportHeaderProps> = ({
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
entityType={entityType}
spendData={spendData}
onExport={onExport}
dateRange={dateValue}
selectedFilters={selectedFilters}
customTitle={customTitle}

View file

@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason";
const state = (overrides: Partial<UsageFetchState> = {}): UsageFetchState => ({
coversRange: true,
cancelled: false,
failed: false,
...overrides,
});
describe("getExportBlockedReason", () => {
it("lets the export through once the data on screen covers the range", () => {
expect(getExportBlockedReason(state())).toBeUndefined();
});
it("blocks whenever the data on screen does not cover the range, which is when a CSV silently under-reports", () => {
expect(getExportBlockedReason(state({ coversRange: false }))).toMatch(/still loading/i);
});
it("blocks after a stopped fetch and says a reload is what fixes it", () => {
const reason = getExportBlockedReason(state({ coversRange: false, cancelled: true }));
expect(reason).toMatch(/stopped/i);
expect(reason).toMatch(/reload/i);
});
it("blocks after a failed page and names the failure rather than the stop", () => {
const reason = getExportBlockedReason(state({ coversRange: false, failed: true, cancelled: true }));
expect(reason).toMatch(/failed to load/i);
expect(reason).not.toMatch(/stopped/i);
});
});

View file

@ -1,13 +0,0 @@
export interface UsageFetchState {
coversRange: boolean;
cancelled: boolean;
failed: boolean;
}
export const getExportBlockedReason = ({ coversRange, cancelled, failed }: 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 (cancelled)
return "Loading was stopped before the whole range arrived, so an export would under-report. Reload the page to load it all.";
if (!coversRange) return "Spend data is still loading, so an export would under-report. Wait for it to finish.";
return undefined;
};

View file

@ -1,69 +1,17 @@
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import type { Team } from "@/components/key_team_helpers/key_list";
import type { DailyActivityEntity, ExportFormat, ExportType } from "@/components/UsagePage/dailyActivityApi";
export type ExportFormat = "csv" | "json";
export type ExportScope = "daily" | "daily_with_keys" | "daily_with_models" | "daily_with_users";
export type EntityType = "tag" | "team" | "organization" | "customer" | "agent" | "user";
export interface EntitySpendData {
results: any[];
metadata: {
total_spend: number;
total_flat_cost?: number;
total_ptu_hours?: number;
total_api_requests: number;
total_successful_requests: number;
total_failed_requests: number;
total_tokens: number;
};
}
export type { ExportFormat, ExportType };
export type EntityType = DailyActivityEntity;
export interface EntityUsageExportModalProps {
isOpen: boolean;
onClose: () => void;
entityType: EntityType;
spendData: EntitySpendData;
onExport: (exportType: ExportType, format: ExportFormat) => Promise<Blob>;
dateRange: DateRangePickerValue;
selectedFilters: string[];
selectedFilters?: string[];
customTitle?: string;
teams?: Team[];
}
export interface ExportMetadata {
export_date: string;
entity_type: string;
date_range: {
from?: string;
to?: string;
};
filters_applied: string[] | string;
export_scope: ExportScope;
summary: {
total_spend: number;
total_flat_cost?: number;
total_cost?: number;
total_ptu_hours?: number;
total_requests: number;
successful_requests: number;
failed_requests: number;
total_tokens: number;
};
}
export interface EntityBreakdown {
metrics: {
spend: number;
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
api_requests: number;
successful_requests: number;
failed_requests: number;
cache_read_input_tokens: number;
cache_creation_input_tokens: number;
};
metadata: {
alias: string;
id: string;
};
}

View file

@ -1,500 +1,23 @@
import { formatNumberWithCommas } from "@/utils/dataUtils";
import type { DateRangePickerValue } from "@/components/shared/date_picker_types";
import Papa from "papaparse";
import { keyActivityLabel } from "@/components/UsagePage/keyActivityLabel";
import type { EntityBreakdown, EntitySpendData, EntityType, ExportMetadata, ExportScope } from "./types";
import type { EntityType, ExportFormat, ExportType } from "./types";
const resolveEntityDisplay = (
entity: string,
teamAliasMap: Record<string, string>,
entityMetadata?: Record<string, any>,
): { id: string; alias: string } => ({
id: entity,
alias:
teamAliasMap[entity] ||
entityMetadata?.team_alias ||
entityMetadata?.user_email ||
entityMetadata?.user_alias ||
entity,
});
const fileDay = (date: Date | undefined): string =>
date
? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
: "all";
// Mirrors backend SpendMetrics fields (litellm/types/activity_tracking.py).
// If the backend adds a field, add it here too.
const METRIC_KEYS = [
"spend",
"api_requests",
"successful_requests",
"failed_requests",
"total_tokens",
"prompt_tokens",
"completion_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
] as const;
// When breakdown.entities is empty (aggregated endpoint), reconstruct entities
// from breakdown.api_keys by grouping on metadata.team_id.
const aggregateApiKeysIntoEntities = (breakdown: Record<string, any>): Record<string, any> => {
const apiKeys = breakdown.api_keys;
if (!apiKeys || Object.keys(apiKeys).length === 0) return {};
const grouped: Record<string, any> = {};
for (const [keyId, keyData] of Object.entries<any>(apiKeys)) {
const teamId = keyData?.metadata?.team_id || "Unassigned";
if (!grouped[teamId]) {
grouped[teamId] = {
metrics: Object.fromEntries(METRIC_KEYS.map((k) => [k, 0])),
api_key_breakdown: {},
};
}
const m = grouped[teamId].metrics;
const km = keyData?.metrics || {};
for (const k of METRIC_KEYS) {
m[k] += km[k] || 0;
}
grouped[teamId].api_key_breakdown[keyId] = keyData;
}
return grouped;
};
// Returns breakdown.entities if populated, otherwise falls back to
// reconstructing entities from breakdown.api_keys.
export const resolveEntities = (breakdown: Record<string, any>): Record<string, any> => {
const entities = breakdown.entities;
if (entities && Object.keys(entities).length > 0) return entities;
return aggregateApiKeysIntoEntities(breakdown);
};
export const getEntityBreakdown = (
spendData: EntitySpendData,
teamAliasMap: Record<string, string> = {},
): EntityBreakdown[] => {
const entitySpend: { [key: string]: EntityBreakdown } = {};
spendData.results.forEach((day) => {
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata);
if (!entitySpend[entity]) {
entitySpend[entity] = {
metrics: {
spend: 0,
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
api_requests: 0,
successful_requests: 0,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
metadata: {
alias,
id,
},
};
}
entitySpend[entity].metrics.spend += data.metrics.spend;
entitySpend[entity].metrics.api_requests += data.metrics.api_requests;
entitySpend[entity].metrics.successful_requests += data.metrics.successful_requests;
entitySpend[entity].metrics.failed_requests += data.metrics.failed_requests;
entitySpend[entity].metrics.total_tokens += data.metrics.total_tokens;
entitySpend[entity].metrics.prompt_tokens += data.metrics.prompt_tokens || 0;
entitySpend[entity].metrics.completion_tokens += data.metrics.completion_tokens || 0;
entitySpend[entity].metrics.cache_read_input_tokens += data.metrics.cache_read_input_tokens || 0;
entitySpend[entity].metrics.cache_creation_input_tokens += data.metrics.cache_creation_input_tokens || 0;
});
});
return Object.values(entitySpend).sort((a, b) => b.metrics.spend - a.metrics.spend);
};
// total_flat_cost defaults to 0 on every entity response, so only a non-zero value
// means a PTU-configured team actually accrued flat cost worth exporting.
const hasFlatCost = (spendData: EntitySpendData): boolean => (spendData.metadata.total_flat_cost ?? 0) > 0;
export const generateDailyData = (
spendData: EntitySpendData,
entityLabel: string,
teamAliasMap: Record<string, string> = {},
): any[] => {
const dailyBreakdown: any[] = [];
const includeFlatCost = hasFlatCost(spendData);
spendData.results.forEach((day) => {
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata);
const row: Record<string, any> = {
Date: day.date,
[entityLabel]: alias,
[`${entityLabel} ID`]: id,
"Spend ($)": formatNumberWithCommas(data.metrics.spend, 4),
};
if (includeFlatCost) {
const flatCost = data.metrics.flat_cost || 0;
row["Flat Cost ($)"] = formatNumberWithCommas(flatCost, 4);
row["Total Cost ($)"] = formatNumberWithCommas((data.metrics.spend || 0) + flatCost, 4);
}
row.Requests = data.metrics.api_requests;
row["Successful Requests"] = data.metrics.successful_requests;
row["Failed Requests"] = data.metrics.failed_requests;
row["Total Tokens"] = data.metrics.total_tokens;
row["Prompt Tokens"] = data.metrics.prompt_tokens || 0;
row["Completion Tokens"] = data.metrics.completion_tokens || 0;
row["Cache Read Input Tokens"] = data.metrics.cache_read_input_tokens || 0;
row["Cache Creation Input Tokens"] = data.metrics.cache_creation_input_tokens || 0;
dailyBreakdown.push(row);
});
});
return dailyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
};
export const generateDailyWithKeysData = (
spendData: EntitySpendData,
entityLabel: string,
teamAliasMap: Record<string, string> = {},
): any[] => {
// Aggregate by unique (Date, Entity ID, Key ID) combination to prevent duplicates
const aggregatedData: {
[key: string]: {
Date: string;
entityId: string;
entityAlias: string;
keyId: string;
keyAlias: string | null;
userId: string | null;
userEmail: string | null;
metrics: {
spend: number;
api_requests: number;
successful_requests: number;
failed_requests: number;
total_tokens: number;
prompt_tokens: number;
completion_tokens: number;
cache_read_input_tokens: number;
cache_creation_input_tokens: number;
};
};
} = {};
spendData.results.forEach((day) => {
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
const { id: entityId, alias: entityAlias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata);
const apiKeyBreakdown = data.api_key_breakdown || {};
// Iterate through each API key in the breakdown
Object.entries(apiKeyBreakdown).forEach(([keyId, keyData]: [string, any]) => {
const keyAlias = keyActivityLabel(keyData?.metadata, "") || null;
// Create unique key for aggregation: Date_EntityID_KeyID
const uniqueKey = `${day.date}_${entityId}_${keyId}`;
if (!aggregatedData[uniqueKey]) {
// First time seeing this (Date, Entity ID, Key ID) combination
aggregatedData[uniqueKey] = {
Date: day.date,
entityId,
entityAlias,
keyId,
keyAlias,
userId: keyData?.metadata?.user_id || null,
userEmail: keyData?.metadata?.user_email || null,
metrics: {
spend: keyData.metrics?.spend || 0,
api_requests: keyData.metrics?.api_requests || 0,
successful_requests: keyData.metrics?.successful_requests || 0,
failed_requests: keyData.metrics?.failed_requests || 0,
total_tokens: keyData.metrics?.total_tokens || 0,
prompt_tokens: keyData.metrics?.prompt_tokens || 0,
completion_tokens: keyData.metrics?.completion_tokens || 0,
cache_read_input_tokens: keyData.metrics?.cache_read_input_tokens || 0,
cache_creation_input_tokens: keyData.metrics?.cache_creation_input_tokens || 0,
},
};
} else {
// Aggregate metrics for existing entry
aggregatedData[uniqueKey].metrics.spend += keyData.metrics?.spend || 0;
aggregatedData[uniqueKey].metrics.api_requests += keyData.metrics?.api_requests || 0;
aggregatedData[uniqueKey].metrics.successful_requests += keyData.metrics?.successful_requests || 0;
aggregatedData[uniqueKey].metrics.failed_requests += keyData.metrics?.failed_requests || 0;
aggregatedData[uniqueKey].metrics.total_tokens += keyData.metrics?.total_tokens || 0;
aggregatedData[uniqueKey].metrics.prompt_tokens += keyData.metrics?.prompt_tokens || 0;
aggregatedData[uniqueKey].metrics.completion_tokens += keyData.metrics?.completion_tokens || 0;
aggregatedData[uniqueKey].metrics.cache_read_input_tokens += keyData.metrics?.cache_read_input_tokens || 0;
aggregatedData[uniqueKey].metrics.cache_creation_input_tokens +=
keyData.metrics?.cache_creation_input_tokens || 0;
}
});
});
});
// Convert aggregated data to array format
const dailyKeyBreakdown = Object.values(aggregatedData).map((item) => ({
Date: item.Date,
[entityLabel]: item.entityAlias,
[`${entityLabel} ID`]: item.entityId,
"Key Alias": item.keyAlias || "-",
"Key ID": item.keyId,
...(entityLabel === "User" ? {} : { "User ID": item.userId || "-", "User Email": item.userEmail || "-" }),
"Spend ($)": formatNumberWithCommas(item.metrics.spend, 4),
Requests: item.metrics.api_requests,
"Successful Requests": item.metrics.successful_requests,
"Failed Requests": item.metrics.failed_requests,
"Total Tokens": item.metrics.total_tokens,
"Prompt Tokens": item.metrics.prompt_tokens,
"Completion Tokens": item.metrics.completion_tokens,
"Cache Read Input Tokens": item.metrics.cache_read_input_tokens,
"Cache Creation Input Tokens": item.metrics.cache_creation_input_tokens,
}));
return dailyKeyBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
};
export const generateDailyWithUsersData = (
spendData: EntitySpendData,
entityLabel: string,
teamAliasMap: Record<string, string> = {},
): any[] => {
const aggregatedData: {
[key: string]: {
Date: string;
entityId: string;
entityAlias: string;
userId: string;
userEmail: string | null;
keyIds: Set<string>;
metrics: Record<(typeof METRIC_KEYS)[number], number>;
};
} = {};
spendData.results.forEach((day) => {
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, data]: [string, any]) => {
const { id: entityId, alias: entityAlias } = resolveEntityDisplay(entity, teamAliasMap, data.metadata);
Object.entries(data.api_key_breakdown || {}).forEach(([keyId, keyData]: [string, any]) => {
const userId = keyData?.metadata?.user_id || "Unassigned";
const uniqueKey = JSON.stringify([day.date, entityId, userId]);
if (!aggregatedData[uniqueKey]) {
aggregatedData[uniqueKey] = {
Date: day.date,
entityId,
entityAlias,
userId,
userEmail: null,
keyIds: new Set(),
metrics: Object.fromEntries(METRIC_KEYS.map((k) => [k, 0])) as Record<(typeof METRIC_KEYS)[number], number>,
};
}
const bucket = aggregatedData[uniqueKey];
bucket.userEmail = bucket.userEmail || keyData?.metadata?.user_email || null;
bucket.keyIds.add(keyId);
for (const k of METRIC_KEYS) {
bucket.metrics[k] += keyData?.metrics?.[k] || 0;
}
});
});
});
return Object.values(aggregatedData)
.map((item) => ({
Date: item.Date,
[entityLabel]: item.entityAlias,
[`${entityLabel} ID`]: item.entityId,
"User ID": item.userId,
"User Email": item.userEmail || "-",
Keys: item.keyIds.size,
"Spend ($)": formatNumberWithCommas(item.metrics.spend, 4),
Requests: item.metrics.api_requests,
"Successful Requests": item.metrics.successful_requests,
"Failed Requests": item.metrics.failed_requests,
"Total Tokens": item.metrics.total_tokens,
"Prompt Tokens": item.metrics.prompt_tokens,
"Completion Tokens": item.metrics.completion_tokens,
"Cache Read Input Tokens": item.metrics.cache_read_input_tokens,
"Cache Creation Input Tokens": item.metrics.cache_creation_input_tokens,
}))
.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
};
export const generateDailyWithModelsData = (
spendData: EntitySpendData,
entityLabel: string,
teamAliasMap: Record<string, string> = {},
): any[] => {
const dailyModelBreakdown: any[] = [];
spendData.results.forEach((day) => {
const dailyEntityModels: { [key: string]: { [key: string]: any } } = {};
const dailyEntityMetadata: { [key: string]: Record<string, any> | undefined } = {};
Object.entries(resolveEntities(day.breakdown)).forEach(([entity, entityData]: [string, any]) => {
if (!dailyEntityModels[entity]) {
dailyEntityModels[entity] = {};
}
dailyEntityMetadata[entity] = entityData.metadata;
Object.entries(day.breakdown.models || {}).forEach(([model, modelData]: [string, any]) => {
const entityApiKeys = entityData.api_key_breakdown || {};
const modelApiKeys = modelData.api_key_breakdown || {};
Object.keys(entityApiKeys).forEach((apiKey) => {
const keyMetrics = modelApiKeys[apiKey]?.metrics;
if (!keyMetrics) return;
if (!dailyEntityModels[entity][model]) {
dailyEntityModels[entity][model] = {
spend: 0,
requests: 0,
successful: 0,
failed: 0,
tokens: 0,
promptTokens: 0,
completionTokens: 0,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
};
}
dailyEntityModels[entity][model].spend += keyMetrics.spend || 0;
dailyEntityModels[entity][model].requests += keyMetrics.api_requests || 0;
dailyEntityModels[entity][model].successful += keyMetrics.successful_requests || 0;
dailyEntityModels[entity][model].failed += keyMetrics.failed_requests || 0;
dailyEntityModels[entity][model].tokens += keyMetrics.total_tokens || 0;
dailyEntityModels[entity][model].promptTokens += keyMetrics.prompt_tokens || 0;
dailyEntityModels[entity][model].completionTokens += keyMetrics.completion_tokens || 0;
dailyEntityModels[entity][model].cacheReadInputTokens += keyMetrics.cache_read_input_tokens || 0;
dailyEntityModels[entity][model].cacheCreationInputTokens += keyMetrics.cache_creation_input_tokens || 0;
});
});
});
Object.entries(dailyEntityModels).forEach(([entity, models]) => {
const { id, alias } = resolveEntityDisplay(entity, teamAliasMap, dailyEntityMetadata[entity]);
Object.entries(models).forEach(([model, metrics]: [string, any]) => {
dailyModelBreakdown.push({
Date: day.date,
[entityLabel]: alias,
[`${entityLabel} ID`]: id,
Model: model,
"Spend ($)": formatNumberWithCommas(metrics.spend, 4),
Requests: metrics.requests,
Successful: metrics.successful,
Failed: metrics.failed,
"Total Tokens": metrics.tokens,
"Prompt Tokens": metrics.promptTokens,
"Completion Tokens": metrics.completionTokens,
"Cache Read Input Tokens": metrics.cacheReadInputTokens,
"Cache Creation Input Tokens": metrics.cacheCreationInputTokens,
});
});
});
});
return dailyModelBreakdown.sort((a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime());
};
export const generateExportData = (
spendData: EntitySpendData,
exportScope: ExportScope,
entityLabel: string,
teamAliasMap: Record<string, string> = {},
): any[] => {
switch (exportScope) {
case "daily":
return generateDailyData(spendData, entityLabel, teamAliasMap);
case "daily_with_keys":
return generateDailyWithKeysData(spendData, entityLabel, teamAliasMap);
case "daily_with_models":
return generateDailyWithModelsData(spendData, entityLabel, teamAliasMap);
case "daily_with_users":
return generateDailyWithUsersData(spendData, entityLabel, teamAliasMap);
default:
return generateDailyData(spendData, entityLabel, teamAliasMap);
}
};
export const generateMetadata = (
export const exportFilename = (
entityType: EntityType,
exportType: ExportType,
format: ExportFormat,
dateRange: DateRangePickerValue,
selectedFilters: string[],
exportScope: ExportScope,
spendData: EntitySpendData,
): ExportMetadata => {
const summary: ExportMetadata["summary"] = {
total_spend: spendData.metadata.total_spend,
total_requests: spendData.metadata.total_api_requests,
successful_requests: spendData.metadata.total_successful_requests,
failed_requests: spendData.metadata.total_failed_requests,
total_tokens: spendData.metadata.total_tokens,
};
if (hasFlatCost(spendData)) {
const flatCost = spendData.metadata.total_flat_cost ?? 0;
summary.total_flat_cost = flatCost;
summary.total_cost = spendData.metadata.total_spend + flatCost;
}
if ((spendData.metadata.total_ptu_hours ?? 0) > 0) {
summary.total_ptu_hours = spendData.metadata.total_ptu_hours;
}
return {
export_date: new Date().toISOString(),
entity_type: entityType,
date_range: {
from: dateRange.from?.toISOString(),
to: dateRange.to?.toISOString(),
},
filters_applied: selectedFilters.length > 0 ? selectedFilters : "None",
export_scope: exportScope,
summary,
};
};
): string => `${entityType}_usage_${exportType}_${fileDay(dateRange.from)}_${fileDay(dateRange.to)}.${format}`;
export const handleExportCSV = (
spendData: EntitySpendData,
exportScope: ExportScope,
entityLabel: string,
entityType: EntityType,
teamAliasMap: Record<string, string> = {},
): void => {
const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap);
const csv = Papa.unparse(data);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
export const downloadBlob = (blob: Blob, filename: string): void => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.csv`;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
};
export const handleExportJSON = (
spendData: EntitySpendData,
exportScope: ExportScope,
entityLabel: string,
entityType: EntityType,
dateRange: DateRangePickerValue,
selectedFilters: string[],
teamAliasMap: Record<string, string> = {},
): void => {
const data = generateExportData(spendData, exportScope, entityLabel, teamAliasMap);
const metadata = generateMetadata(entityType, dateRange, selectedFilters, exportScope, spendData);
const exportObject = {
metadata,
data,
};
const jsonString = JSON.stringify(exportObject, null, 2);
const blob = new Blob([jsonString], { type: "application/json" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const fileName = `${entityType}_usage_${exportScope}_${new Date().toISOString().split("T")[0]}.json`;
a.download = fileName;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);

View file

@ -0,0 +1,674 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { components } from "@/lib/http/schema";
import { type DailyActivityKeyPageResponse, type KeyActivityRow, type KeySpendActivityRow } from "../dailyActivityApi";
import type { ModelActivityData } from "../types";
import KeyActivityPanel from "./KeyActivityPanel";
let triggerIntersection: (() => void) | undefined;
const intersectSentinel = async () => {
await waitFor(() => expect(triggerIntersection).toBeDefined());
await act(async () => {
triggerIntersection?.();
});
};
class TestIntersectionObserver implements IntersectionObserver {
readonly root: Element | Document | null = null;
readonly rootMargin = "";
readonly thresholds: readonly number[] = [];
constructor(private readonly callback: IntersectionObserverCallback) {}
observe(target: Element): void {
triggerIntersection = () =>
this.callback(
[
{
boundingClientRect: target.getBoundingClientRect(),
intersectionRect: target.getBoundingClientRect(),
intersectionRatio: 1,
isIntersecting: true,
rootBounds: null,
target,
time: 0,
},
],
this,
);
}
unobserve(): void {}
disconnect(): void {
triggerIntersection = undefined;
}
takeRecords(): IntersectionObserverEntry[] {
return [];
}
}
const metrics: components["schemas"]["SpendMetrics"] = {
api_requests: 2,
autorouter_savings_spend: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
completion_tokens: 3,
compression_saved_tokens: 0,
compression_savings_spend: 0,
failed_requests: 0,
flat_cost: 0,
gateway_injected_caching_savings_spend: 0,
prompt_caching_savings_spend: 0,
prompt_tokens: 4,
spend: 1.25,
successful_requests: 2,
timed_requests: 0,
total_response_time_ms: 0,
total_tokens: 7,
};
const pageRow = (apiKey: string): KeySpendActivityRow => ({
api_key: apiKey,
metrics: {
api_requests: 2,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
completion_tokens: 3,
failed_requests: 0,
prompt_tokens: 4,
spend: 1.25,
successful_requests: 2,
total_tokens: 7,
},
metadata: { key_alias: apiKey, team_id: null },
});
const searchRow = (apiKey: string, alias: string): KeyActivityRow => ({
api_key: apiKey,
metrics,
metadata: { key_alias: alias, team_id: null },
});
const pageResponse = (apiKeys: KeySpendActivityRow[], total: number, offset = 0): DailyActivityKeyPageResponse => ({
api_keys: apiKeys,
total_api_keys: total,
offset,
limit: 50,
});
const summary: ModelActivityData = {
label: "Overall Usage",
total_requests: 200,
total_successful_requests: 198,
total_failed_requests: 2,
total_cache_read_input_tokens: 0,
total_cache_creation_input_tokens: 0,
total_tokens: 700,
prompt_tokens: 400,
completion_tokens: 300,
total_spend: 500,
total_response_time_ms: 0,
total_timed_requests: 0,
top_models: [],
daily_data: [
{
date: "2026-09-27",
metrics: {
prompt_tokens: 4,
completion_tokens: 3,
total_tokens: 7,
api_requests: 2,
spend: 1.25,
successful_requests: 2,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
},
],
};
const detail = (apiKey: string): ModelActivityData => ({
label: apiKey,
total_requests: 2,
total_successful_requests: 2,
total_failed_requests: 0,
total_cache_read_input_tokens: 0,
total_cache_creation_input_tokens: 0,
total_tokens: 7,
prompt_tokens: 4,
completion_tokens: 3,
total_spend: 1.25,
total_response_time_ms: 0,
total_timed_requests: 0,
top_models: [
{ model: "gpt-4o-mini", spend: 1.25, requests: 2, successful_requests: 2, failed_requests: 0, tokens: 7 },
],
daily_data: [
{
date: "2026-09-27",
metrics: {
prompt_tokens: 4,
completion_tokens: 3,
total_tokens: 7,
api_requests: 2,
spend: 1.25,
successful_requests: 2,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
avg_response_time_ms: null,
},
},
],
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
triggerIntersection = undefined;
});
beforeEach(() => {
vi.stubGlobal("IntersectionObserver", TestIntersectionObserver);
});
describe("KeyActivityPanel", () => {
it("loads the first page, appends the next page, and keeps limit copy out of the UI", async () => {
let resolveNextPage: (response: DailyActivityKeyPageResponse) => void = () => {};
const nextPage = new Promise<DailyActivityKeyPageResponse>((resolve) => {
resolveNextPage = resolve;
});
const firstPageRows = Array.from({ length: 50 }, (_, index) => pageRow(`key-${index}`));
const fetchKeyPage = vi.fn((offset: number, _limit: number) =>
offset === 0 ? Promise.resolve(pageResponse(firstPageRows, 52)) : nextPage,
);
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
teams={[]}
/>,
);
expect(await screen.findByRole("button", { name: /key-49/ })).toBeInTheDocument();
expect(screen.getByText("52 keys")).toBeInTheDocument();
expect(screen.getByText("$500.00")).toBeInTheDocument();
expect(fetchKeyPage).toHaveBeenCalledWith(0, 50);
expect(screen.queryByText(/limit|truncat|highest-spend|load top/i)).not.toBeInTheDocument();
await intersectSentinel();
expect(fetchKeyPage).toHaveBeenCalledWith(50, 50);
expect(screen.getByText("Loading more keys...")).toBeInTheDocument();
await act(async () => {
resolveNextPage(pageResponse([pageRow("key-50"), pageRow("key-51")], 52, 50));
await nextPage;
});
expect(await screen.findByRole("button", { name: /key-51/ })).toBeInTheDocument();
expect(screen.queryByText("Loading more keys...")).not.toBeInTheDocument();
});
it("fetches full detail on first expansion and renders charts with daily data", async () => {
const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-chart")], 1));
let resolveDetail: (metrics: ModelActivityData) => void = () => {};
const detailResponse = new Promise<ModelActivityData>((resolve) => {
resolveDetail = resolve;
});
const fetchKeyDetail = vi.fn().mockReturnValue(detailResponse);
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={fetchKeyDetail}
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
teams={[]}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: /key-chart/ }));
expect(fetchKeyDetail).toHaveBeenCalledWith("key-chart");
expect(await screen.findByText("Loading key details...")).toBeInTheDocument();
await act(async () => {
resolveDetail(detail("key-chart"));
await detailResponse;
});
expect(await screen.findByText("Spend per day")).toBeInTheDocument();
expect(screen.getByText("Requests per day")).toBeInTheDocument();
expect(screen.queryByText("No data")).not.toBeInTheDocument();
});
it("shows a detail error with Retry when the detail request fails and refetches on retry", async () => {
const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-broken")], 1));
const fetchKeyDetail = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce(detail("key-broken"));
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={fetchKeyDetail}
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
teams={[]}
/>,
);
fireEvent.click(await screen.findByRole("button", { name: /key-broken/ }));
expect(await screen.findByText(/Could not load key details\./)).toBeInTheDocument();
expect(fetchKeyDetail).toHaveBeenCalledTimes(1);
expect(screen.queryByText("Spend per day")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(fetchKeyDetail).toHaveBeenCalledTimes(2);
expect(fetchKeyDetail).toHaveBeenLastCalledWith("key-broken");
expect(await screen.findByText("Spend per day")).toBeInTheDocument();
expect(screen.queryByText(/Could not load key details\./)).not.toBeInTheDocument();
consoleError.mockRestore();
});
it("shows a loader instead of zero totals while the summary is in flight", async () => {
const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local")], 1));
const zeroSummary: ModelActivityData = { ...summary, total_spend: 0, total_requests: 0, total_tokens: 0 };
const props = {
fetchKeyPage,
fetchKeyDetail: vi.fn(),
searchKeys: vi.fn().mockResolvedValue({ api_keys: [] }),
teams: [],
};
const { rerender } = render(<KeyActivityPanel summary={zeroSummary} summaryLoading {...props} />);
expect(await screen.findByRole("button", { name: /key-local/ })).toBeInTheDocument();
expect(screen.getByText("Loading chart data...")).toBeInTheDocument();
expect(screen.queryByText("Overall Usage")).not.toBeInTheDocument();
expect(screen.queryByText("$0.00")).not.toBeInTheDocument();
rerender(<KeyActivityPanel summary={summary} summaryLoading={false} {...props} />);
expect(screen.queryByText("Loading chart data...")).not.toBeInTheDocument();
expect(screen.getByText("Overall Usage")).toBeInTheDocument();
expect(screen.getAllByText("$500.00").length).toBeGreaterThan(0);
});
it("loads details for remote search results and merges local matches", async () => {
vi.useFakeTimers();
const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local-remote")], 2));
const fetchKeyDetail = vi.fn().mockResolvedValue(detail("key-remote"));
const searchKeys = vi.fn().mockResolvedValue({
api_keys: [searchRow("key-remote", "remote server result")],
});
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={fetchKeyDetail}
searchKeys={searchKeys}
teams={[]}
/>,
);
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } });
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
vi.useRealTimers();
expect(searchKeys).toHaveBeenCalledWith("remote");
expect(screen.getByText("2 matching keys")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /key-local-remote/ })).toBeInTheDocument();
const remoteButton = screen.getByRole("button", { name: /remote server result/ });
fireEvent.click(remoteButton);
expect(fetchKeyDetail).toHaveBeenCalledWith("key-remote");
expect(await screen.findByText("Spend per day")).toBeInTheDocument();
expect(screen.queryByText("No data")).not.toBeInTheDocument();
});
it("shows an error with Retry when the first page fails and recovers on retry", async () => {
let resolveRetry: (page: DailyActivityKeyPageResponse) => void = () => undefined;
const fetchKeyPage = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockImplementationOnce(
() =>
new Promise<DailyActivityKeyPageResponse>((resolve) => {
resolveRetry = resolve;
}),
);
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
teams={[]}
/>,
);
expect(await screen.findByText("Could not load keys for this range.")).toBeInTheDocument();
expect(screen.queryByText("0 keys")).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(screen.getByText("Loading keys...")).toBeInTheDocument();
expect(screen.queryByText("Could not load keys for this range.")).not.toBeInTheDocument();
expect(fetchKeyPage).toHaveBeenCalledTimes(2);
expect(fetchKeyPage).toHaveBeenNthCalledWith(2, 0, 50);
await act(async () => {
resolveRetry(pageResponse([pageRow("key-after-retry")], 1));
});
expect(await screen.findByRole("button", { name: /key-after-retry/ })).toBeInTheDocument();
expect(screen.queryByText("Loading keys...")).not.toBeInTheDocument();
});
it("stops auto-retrying after a failed next page until Retry is clicked", async () => {
const firstPageRows = Array.from({ length: 50 }, (_, index) => pageRow(`key-${index}`));
const fetchKeyPage = vi
.fn()
.mockResolvedValueOnce(pageResponse(firstPageRows, 52))
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValue(pageResponse([pageRow("key-50"), pageRow("key-51")], 52, 50));
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
teams={[]}
/>,
);
expect(await screen.findByRole("button", { name: /key-49/ })).toBeInTheDocument();
await intersectSentinel();
expect(await screen.findByText("Could not load more keys.")).toBeInTheDocument();
expect(fetchKeyPage).toHaveBeenCalledTimes(2);
await act(async () => {
triggerIntersection?.();
});
expect(fetchKeyPage).toHaveBeenCalledTimes(2);
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByRole("button", { name: /key-51/ })).toBeInTheDocument();
expect(fetchKeyPage).toHaveBeenCalledTimes(3);
expect(fetchKeyPage).toHaveBeenNthCalledWith(3, 50, 50);
});
it("hides the next-page Retry while searching and refetches the failed page once the search is cleared", async () => {
const firstPageRows = Array.from({ length: 50 }, (_, index) => pageRow(`key-${index}`));
const fetchKeyPage = vi
.fn()
.mockResolvedValueOnce(pageResponse(firstPageRows, 52))
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValue(pageResponse([pageRow("key-50"), pageRow("key-51")], 52, 50));
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
teams={[]}
/>,
);
expect(await screen.findByRole("button", { name: /key-49/ })).toBeInTheDocument();
await intersectSentinel();
expect(await screen.findByText("Could not load more keys.")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "key-1" } });
expect(screen.queryByText("Could not load more keys.")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Clear key search" }));
expect(await screen.findByText("Could not load more keys.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByRole("button", { name: /key-51/ })).toBeInTheDocument();
expect(fetchKeyPage).toHaveBeenCalledTimes(3);
expect(fetchKeyPage).toHaveBeenNthCalledWith(3, 50, 50);
});
it("shows server search matches even when the first key page failed", async () => {
vi.useFakeTimers();
const fetchKeyPage = vi.fn().mockRejectedValue(new Error("boom"));
const searchKeys = vi.fn().mockResolvedValue({
api_keys: [searchRow("key-remote", "remote server result")],
});
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={vi.fn().mockResolvedValue(detail("key-remote"))}
searchKeys={searchKeys}
teams={[]}
/>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(screen.getByText("Could not load keys for this range.")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } });
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
vi.useRealTimers();
expect(searchKeys).toHaveBeenCalledWith("remote");
expect(screen.getByRole("button", { name: /remote server result/ })).toBeInTheDocument();
expect(screen.getByText("1 matching keys")).toBeInTheDocument();
expect(screen.queryByText("Could not load keys for this range.")).not.toBeInTheDocument();
});
it("keeps the loader and the first page error visible for a short query instead of No keys match", async () => {
let resolveFirstPage: (page: DailyActivityKeyPageResponse) => void = () => undefined;
const fetchKeyPage = vi.fn().mockImplementationOnce(
() =>
new Promise<DailyActivityKeyPageResponse>((resolve) => {
resolveFirstPage = resolve;
}),
);
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
teams={[]}
/>,
);
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "a" } });
expect(screen.getByText("Loading keys...")).toBeInTheDocument();
expect(screen.queryByText(/No keys match/)).not.toBeInTheDocument();
expect(screen.queryByText("0 matching keys")).not.toBeInTheDocument();
await act(async () => {
resolveFirstPage(pageResponse([pageRow("key-alpha")], 1));
});
expect(await screen.findByRole("button", { name: /key-alpha/ })).toBeInTheDocument();
expect(screen.getByText("1 matching keys")).toBeInTheDocument();
cleanup();
const failingFetch = vi.fn().mockRejectedValue(new Error("boom"));
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={failingFetch}
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
teams={[]}
/>,
);
expect(await screen.findByText("Could not load keys for this range.")).toBeInTheDocument();
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "a" } });
expect(screen.getByText("Could not load keys for this range.")).toBeInTheDocument();
expect(screen.queryByText(/No keys match/)).not.toBeInTheDocument();
expect(screen.queryByText("0 matching keys")).not.toBeInTheDocument();
});
it("does not restart an in-flight search when the parent passes a new teams array", async () => {
vi.useFakeTimers();
const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local")], 1));
let resolveSearch: (response: { api_keys: KeyActivityRow[] }) => void = () => undefined;
const searchKeys = vi.fn().mockImplementation(
() =>
new Promise<{ api_keys: KeyActivityRow[] }>((resolve) => {
resolveSearch = resolve;
}),
);
const props = {
summary,
fetchKeyPage,
fetchKeyDetail: vi.fn().mockResolvedValue(detail("key-remote")),
searchKeys,
};
const { rerender } = render(<KeyActivityPanel {...props} teams={[]} />);
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } });
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(searchKeys).toHaveBeenCalledTimes(1);
rerender(<KeyActivityPanel {...props} teams={[]} />);
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(searchKeys).toHaveBeenCalledTimes(1);
await act(async () => {
resolveSearch({ api_keys: [searchRow("key-remote", "remote server result")] });
});
vi.useRealTimers();
expect(await screen.findByRole("button", { name: /remote server result/ })).toBeInTheDocument();
expect(screen.getByText("1 matching keys")).toBeInTheDocument();
});
it("keeps paging past a page of already loaded keys by server offset instead of loaded row count", async () => {
const fetchKeyPage = vi
.fn()
.mockResolvedValueOnce(pageResponse([pageRow("key-1"), pageRow("key-2")], 5))
.mockResolvedValueOnce(pageResponse([pageRow("key-1"), pageRow("key-2")], 5, 2))
.mockResolvedValueOnce(pageResponse([pageRow("key-5")], 5, 4));
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
teams={[]}
/>,
);
expect(await screen.findByRole("button", { name: /key-2/ })).toBeInTheDocument();
await intersectSentinel();
expect(fetchKeyPage).toHaveBeenNthCalledWith(2, 2, 50);
await intersectSentinel();
expect(fetchKeyPage).toHaveBeenNthCalledWith(3, 4, 50);
expect(await screen.findByRole("button", { name: /key-5/ })).toBeInTheDocument();
expect(fetchKeyPage).toHaveBeenCalledTimes(3);
expect(screen.getAllByRole("button", { name: /key-/ })).toHaveLength(3);
});
it("stops requesting more keys when a later page is empty even though the total says more exist", async () => {
const fetchKeyPage = vi
.fn()
.mockResolvedValueOnce(pageResponse([pageRow("key-1"), pageRow("key-2")], 3))
.mockResolvedValue(pageResponse([], 3, 2));
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={vi.fn().mockResolvedValue(detail("unused"))}
searchKeys={vi.fn().mockResolvedValue({ api_keys: [] })}
teams={[]}
/>,
);
expect(await screen.findByRole("button", { name: /key-2/ })).toBeInTheDocument();
await intersectSentinel();
await act(async () => {
triggerIntersection?.();
});
expect(fetchKeyPage).toHaveBeenCalledTimes(2);
expect(fetchKeyPage).toHaveBeenLastCalledWith(2, 50);
expect(triggerIntersection).toBeUndefined();
expect(screen.getByText("3 keys")).toBeInTheDocument();
});
it("shows a search error with Retry search instead of No keys match when the search fails", async () => {
vi.useFakeTimers();
const fetchKeyPage = vi.fn().mockResolvedValue(pageResponse([pageRow("key-local")], 1));
const searchKeys = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValue({ api_keys: [searchRow("key-remote", "remote server result")] });
render(
<KeyActivityPanel
summary={summary}
fetchKeyPage={fetchKeyPage}
fetchKeyDetail={vi.fn().mockResolvedValue(detail("key-remote"))}
searchKeys={searchKeys}
teams={[]}
/>,
);
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } });
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
expect(searchKeys).toHaveBeenCalledTimes(1);
expect(screen.getByText(/Could not search keys\./)).toBeInTheDocument();
expect(screen.queryByText(/No keys match/)).not.toBeInTheDocument();
expect(screen.queryByText("0 matching keys")).not.toBeInTheDocument();
expect(screen.getByText("Search failed")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Retry search" }));
expect(screen.getByText("Searching...")).toBeInTheDocument();
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
vi.useRealTimers();
expect(searchKeys).toHaveBeenCalledTimes(2);
expect(screen.getByRole("button", { name: /remote server result/ })).toBeInTheDocument();
expect(screen.queryByText(/Could not search keys\./)).not.toBeInTheDocument();
});
it("discards stale pages and clears loaded keys when the scope changes", async () => {
let resolveOldPage: (response: DailyActivityKeyPageResponse) => void = () => {};
const oldPage = new Promise<DailyActivityKeyPageResponse>((resolve) => {
resolveOldPage = resolve;
});
const firstScopeFetch = vi.fn().mockReturnValue(oldPage);
const secondScopeFetch = vi.fn().mockResolvedValue(pageResponse([pageRow("new-scope-key")], 1));
const props = {
summary,
fetchKeyDetail: vi.fn().mockResolvedValue(detail("new-scope-key")),
searchKeys: vi.fn().mockResolvedValue({ api_keys: [] }),
teams: [],
};
const { rerender } = render(<KeyActivityPanel {...props} fetchKeyPage={firstScopeFetch} />);
rerender(<KeyActivityPanel {...props} fetchKeyPage={secondScopeFetch} />);
expect(await screen.findByRole("button", { name: /new-scope-key/ })).toBeInTheDocument();
await act(async () => {
resolveOldPage(pageResponse([pageRow("old-scope-key")], 1));
await oldPage;
});
expect(screen.queryByRole("button", { name: /old-scope-key/ })).not.toBeInTheDocument();
expect(secondScopeFetch).toHaveBeenCalledWith(0, 50);
});
});

View file

@ -1,71 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { ModelActivityData } from "../types";
import KeyActivityPanel from "./KeyActivityPanel";
vi.mock("@/components/activity_metrics", () => ({
ActivityMetrics: ({ modelMetrics }: { modelMetrics: Record<string, ModelActivityData> }) => (
<ul data-testid="rendered-keys">
{Object.keys(modelMetrics).map((hash) => (
<li key={hash}>{hash}</li>
))}
</ul>
),
}));
function activity(label: string, user_email: string | null, user_id: string | null): ModelActivityData {
return {
label,
key_metadata: { key_alias: label, team_id: "team-1", user_id, user_email },
total_requests: 1,
total_successful_requests: 1,
total_failed_requests: 0,
total_cache_read_input_tokens: 0,
total_cache_creation_input_tokens: 0,
total_tokens: 10,
prompt_tokens: 5,
completion_tokens: 5,
total_spend: 0.01,
top_api_keys: [],
top_models: [],
daily_data: [],
};
}
const keyMetrics: Record<string, ModelActivityData> = {
"hash-alice": activity("alice-key", "alice@example.com", "user-alice"),
"hash-bob": activity("bob-key", "bob@example.com", "user-bob"),
};
describe("KeyActivityPanel", () => {
it("renders every key and the full count before searching", () => {
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
expect(screen.getByText("Showing 2 of 2 keys")).toBeInTheDocument();
});
it("narrows the rendered keys to those matching the user email", () => {
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "bob@example.com" } });
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-bob");
expect(screen.getByTestId("rendered-keys")).not.toHaveTextContent("hash-alice");
expect(screen.getByText("Showing 1 of 2 keys")).toBeInTheDocument();
});
it("shows an empty state instead of zeroed metrics when nothing matches", () => {
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "carol" } });
expect(screen.queryByTestId("rendered-keys")).not.toBeInTheDocument();
expect(screen.getByText('No keys match "carol" in this date range')).toBeInTheDocument();
});
it("clears the search and restores every key", () => {
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "user-alice" } });
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alice");
fireEvent.click(screen.getByLabelText("Clear key search"));
expect(screen.getByLabelText("Search keys")).toHaveValue("");
expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alicehash-bob");
});
});

View file

@ -1,27 +1,397 @@
import { Search, X } from "lucide-react";
import React, { useMemo, useState } from "react";
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { ActivityMetrics } from "@/components/activity_metrics";
import { ActivityMetrics, ModelCollapsible, ModelSection } from "@/components/activity_metrics";
import type { Team } from "@/components/key_team_helpers/key_list";
import { ChartLoader } from "@/components/shared/chart_loader";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group";
import type {
DailyActivityKeyPageResponse,
DailyActivityKeySearchResponse,
KeyActivityRow,
KeySpendActivityRow,
} from "../dailyActivityApi";
import { filterKeyActivity } from "../keyActivityFilter";
import { keyActivityRowsToMetrics } from "./keySearch";
import { mergeKeyActivityPages } from "../keyActivityData";
import type { ModelActivityData } from "../types";
const PAGE_SIZE = 50;
const SEARCH_DEBOUNCE_MS = 300;
const MIN_SEARCH_LENGTH = 2;
type FetchKeyPage = (offset: number, limit: number) => Promise<DailyActivityKeyPageResponse>;
type FetchKeyDetail = (apiKey: string) => Promise<ModelActivityData | undefined>;
type SearchKeys = (search: string) => Promise<DailyActivityKeySearchResponse>;
interface KeyActivityPanelProps {
keyMetrics: Record<string, ModelActivityData>;
summary: ModelActivityData;
summaryLoading?: boolean;
fetchKeyPage: FetchKeyPage;
fetchKeyDetail: FetchKeyDetail;
searchKeys: SearchKeys;
teams: Team[];
hidePromptCachingMetrics?: boolean;
}
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ keyMetrics, hidePromptCachingMetrics = false }) => {
const [query, setQuery] = useState("");
const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]);
const totalKeys = Object.keys(keyMetrics).length;
const shownKeys = Object.keys(filtered).length;
const isFiltering = query.trim() !== "";
interface KeyPageState {
scope: FetchKeyPage;
rows: KeySpendActivityRow[];
total: number;
nextOffset: number;
hasMore: boolean;
loading: boolean;
loadingMore: boolean;
failed: boolean;
}
type KeyDetailState =
| { status: "loading" }
| { status: "failed" }
| { status: "loaded"; metrics: ModelActivityData | undefined };
const keyCountText = (searching: boolean, isFiltering: boolean, shownKeys: number, total: number): string => {
if (searching) return "Searching...";
if (isFiltering) return `${shownKeys.toLocaleString()} matching keys`;
return `${total.toLocaleString()} keys`;
};
interface KeyDetailContentProps {
apiKey: string;
detail: KeyDetailState | undefined;
hidePromptCachingMetrics: boolean;
onRetry: () => void;
}
const KeyDetailContent: React.FC<KeyDetailContentProps> = ({ apiKey, detail, hidePromptCachingMetrics, onRetry }) => {
if (detail?.status === "loading") {
return <p className="py-4 text-sm text-muted-foreground">Loading key details...</p>;
}
if (detail?.status === "failed") {
return (
<p className="py-4 text-sm text-muted-foreground">
Could not load key details.{" "}
<button type="button" className="font-medium text-foreground underline" onClick={onRetry}>
Retry
</button>
</p>
);
}
if (detail?.status !== "loaded" || detail.metrics === undefined) {
return <p className="py-4 text-sm text-muted-foreground">No daily details available</p>;
}
return (
<ModelSection modelName={apiKey} metrics={detail.metrics} hidePromptCachingMetrics={hidePromptCachingMetrics} />
);
};
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
summary,
summaryLoading = false,
fetchKeyPage,
fetchKeyDetail,
searchKeys,
teams,
hidePromptCachingMetrics = false,
}) => {
const [queryState, setQueryState] = useState<{ scope: FetchKeyPage; value: string } | null>(null);
const query = queryState?.scope === fetchKeyPage ? queryState.value : "";
const updateQuery = useCallback((value: string) => setQueryState({ scope: fetchKeyPage, value }), [fetchKeyPage]);
const [pageState, setPageState] = useState<KeyPageState | null>(null);
const [detailState, setDetailState] = useState<{
scope: FetchKeyPage;
details: Record<string, KeyDetailState>;
} | null>(null);
const [searchResult, setSearchResult] = useState<{
scope: FetchKeyPage;
term: string;
searchKeys: SearchKeys;
rows: KeyActivityRow[];
failed: boolean;
} | null>(null);
const [retryToken, setRetryToken] = useState(0);
const [searchRetryToken, setSearchRetryToken] = useState(0);
const searchIdRef = useRef(0);
const pageRequestIdRef = useRef(0);
const loadingMoreRef = useRef(false);
const activeFetcherRef = useRef(fetchKeyPage);
const sentinelRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
activeFetcherRef.current = fetchKeyPage;
}, [fetchKeyPage]);
useEffect(() => {
const requestId = ++pageRequestIdRef.current;
loadingMoreRef.current = false;
void fetchKeyPage(0, PAGE_SIZE)
.then((page) => {
if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return;
const loadedPageState = {
scope: fetchKeyPage,
rows: page.api_keys,
total: page.total_api_keys,
nextOffset: page.api_keys.length,
hasMore: page.api_keys.length > 0 && page.api_keys.length < page.total_api_keys,
loading: false,
loadingMore: false,
failed: false,
};
setPageState(loadedPageState);
})
.catch((error: unknown) => {
if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return;
console.error("Key activity page request failed:", error);
const failedPageState = {
scope: fetchKeyPage,
rows: [],
total: 0,
nextOffset: 0,
hasMore: false,
loading: false,
loadingMore: false,
failed: true,
};
setPageState(failedPageState);
});
return () => {
if (pageRequestIdRef.current === requestId) pageRequestIdRef.current += 1;
};
}, [fetchKeyPage, retryToken]);
const currentPageState = pageState?.scope === fetchKeyPage ? pageState : null;
const pageRows = useMemo(() => currentPageState?.rows ?? [], [currentPageState]);
const loading = currentPageState?.loading ?? true;
const loadingMore = currentPageState?.loadingMore ?? false;
const hasMore = currentPageState?.hasMore ?? false;
const total = currentPageState?.total ?? 0;
const failed = currentPageState?.failed ?? false;
const pageMetrics = useMemo(() => keyActivityRowsToMetrics(pageRows, teams), [pageRows, teams]);
const loadMore = useCallback(() => {
if (currentPageState === null) return;
if (currentPageState.loading || currentPageState.loadingMore || !currentPageState.hasMore) return;
if (query.trim() !== "" || loadingMoreRef.current) return;
const requestId = pageRequestIdRef.current;
const offset = currentPageState.nextOffset;
loadingMoreRef.current = true;
setPageState((current) => (current?.scope === fetchKeyPage ? { ...current, loadingMore: true } : current));
void fetchKeyPage(offset, PAGE_SIZE)
.then((page) => {
if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return;
setPageState((current) => {
if (current?.scope !== fetchKeyPage) return current;
const merged = mergeKeyActivityPages(current.rows, page.api_keys, page.total_api_keys, offset);
return {
...current,
rows: merged.rows,
total: page.total_api_keys,
nextOffset: merged.nextOffset,
hasMore: merged.hasMore,
loadingMore: false,
failed: false,
};
});
})
.catch((error: unknown) => {
if (pageRequestIdRef.current !== requestId || activeFetcherRef.current !== fetchKeyPage) return;
console.error("Key activity page request failed:", error);
setPageState((current) =>
current?.scope === fetchKeyPage ? { ...current, loadingMore: false, failed: true } : current,
);
})
.finally(() => {
if (pageRequestIdRef.current === requestId) loadingMoreRef.current = false;
});
}, [currentPageState, fetchKeyPage, query]);
useEffect(() => {
const sentinel = sentinelRef.current;
if (sentinel === null || currentPageState === null) return;
if (currentPageState.failed) return;
if (loading || loadingMore || !hasMore) return;
if (query.trim() !== "") return;
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) loadMore();
});
observer.observe(sentinel);
return () => observer.disconnect();
}, [currentPageState, hasMore, loadMore, loading, loadingMore, query]);
const trimmedQuery = query.trim();
const searchTerm = trimmedQuery.length >= MIN_SEARCH_LENGTH ? trimmedQuery : null;
useEffect(() => {
if (searchTerm === null) return;
const searchId = ++searchIdRef.current;
const timer = setTimeout(() => {
void searchKeys(searchTerm)
.then((response) => {
if (searchIdRef.current !== searchId || activeFetcherRef.current !== fetchKeyPage) return;
const result = { scope: fetchKeyPage, term: searchTerm, searchKeys, rows: response.api_keys, failed: false };
setSearchResult(result);
})
.catch((error: unknown) => {
if (searchIdRef.current !== searchId || activeFetcherRef.current !== fetchKeyPage) return;
console.error("Key activity search failed:", error);
const failedResult = { scope: fetchKeyPage, term: searchTerm, searchKeys, rows: [], failed: true };
setSearchResult(failedResult);
});
}, SEARCH_DEBOUNCE_MS);
return () => {
clearTimeout(timer);
if (searchIdRef.current === searchId) searchIdRef.current += 1;
};
}, [fetchKeyPage, searchKeys, searchRetryToken, searchTerm]);
const currentSearch =
searchResult?.scope === fetchKeyPage && searchResult.term === searchTerm && searchResult.searchKeys === searchKeys
? searchResult
: null;
const searching = searchTerm !== null && currentSearch === null;
const searchFailed = currentSearch?.failed ?? false;
const searchRows = currentSearch?.rows;
const searchMetrics = useMemo(() => keyActivityRowsToMetrics(searchRows ?? [], teams), [searchRows, teams]);
const localFiltered = useMemo(() => filterKeyActivity(pageMetrics, query), [pageMetrics, query]);
const filteredMetrics = useMemo(() => ({ ...searchMetrics, ...localFiltered }), [searchMetrics, localFiltered]);
const shownKeys = Object.keys(filteredMetrics).length;
const isFiltering = trimmedQuery.length > 0;
const visibleDetails = detailState?.scope === fetchKeyPage ? detailState.details : {};
const keyCountLabel =
isFiltering && searchFailed && !searching && shownKeys === 0
? "Search failed"
: keyCountText(searching, isFiltering, shownKeys, total);
const retryFirstPage = () => {
setPageState({
scope: fetchKeyPage,
rows: [],
total: 0,
nextOffset: 0,
hasMore: false,
loading: true,
loadingMore: false,
failed: false,
});
setRetryToken((token) => token + 1);
};
const retrySearch = () => {
setSearchResult(null);
setSearchRetryToken((token) => token + 1);
};
const retryNextPage = () => {
setPageState((current) => (current?.scope === fetchKeyPage ? { ...current, failed: false } : current));
loadMore();
};
const firstPageError = (
<p className="p-4 text-sm text-muted-foreground">
Could not load keys for this range.{" "}
<button type="button" className="font-medium text-foreground underline" onClick={retryFirstPage}>
Retry
</button>
</p>
);
const emptyListBody = (() => {
if (failed) return firstPageError;
if (!loading) return null;
return <p className="p-4 text-sm text-muted-foreground">Loading keys...</p>;
})();
const keyList = Object.entries(filteredMetrics).map(([apiKey, metrics]) => {
const detail = visibleDetails[apiKey];
return (
<ModelCollapsible
key={apiKey}
defaultOpen={false}
onFirstOpen={() => loadKeyDetail(apiKey)}
header={
<div className="flex w-full items-center justify-between gap-4">
<span className="truncate font-medium text-foreground">{metrics.label}</span>
<span className="shrink-0 space-x-4 text-sm text-muted-foreground">
<span>
$
{metrics.total_spend.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}
</span>
<span>{metrics.total_requests.toLocaleString()} requests</span>
</span>
</div>
}
>
<KeyDetailContent
apiKey={apiKey}
detail={detail}
hidePromptCachingMetrics={hidePromptCachingMetrics}
onRetry={() => loadKeyDetail(apiKey)}
/>
</ModelCollapsible>
);
});
const firstPageFailed = failed && pageRows.length === 0;
const showNextPageRetry = failed && !firstPageFailed && !isFiltering;
const localOnly = searchTerm === null;
const pageUnavailable = localOnly && pageRows.length === 0 && (loading || firstPageFailed);
const listBody = pageUnavailable || (!isFiltering && pageRows.length === 0) ? emptyListBody : keyList;
const noMatches = isFiltering && !searching && !pageUnavailable && shownKeys === 0;
const showSearchErrorNote = !noMatches && searchFailed && !searching;
const searchRetryButton = (
<button type="button" className="font-medium text-foreground underline" onClick={retrySearch}>
Retry search
</button>
);
const emptyFilterBody = searchFailed ? (
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
Could not search keys. {searchRetryButton}
</p>
) : (
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
No keys match &quot;{trimmedQuery}&quot; in this date range
</p>
);
const loadKeyDetail = useCallback(
(apiKey: string) => {
const existingDetails = detailState?.scope === fetchKeyPage ? detailState.details : {};
const existing = existingDetails[apiKey];
if (existing !== undefined && existing.status !== "failed") return;
setDetailState((current) => {
const currentDetails = current?.scope === fetchKeyPage ? current.details : {};
return { scope: fetchKeyPage, details: { ...currentDetails, [apiKey]: { status: "loading" } } };
});
void fetchKeyDetail(apiKey)
.then((metrics) => {
setDetailState((current) => {
if (current?.scope !== fetchKeyPage) return current;
return { scope: fetchKeyPage, details: { ...current.details, [apiKey]: { status: "loaded", metrics } } };
});
})
.catch((error: unknown) => {
console.error("Key activity detail request failed:", error);
setDetailState((current) => {
if (current?.scope !== fetchKeyPage) return current;
return {
scope: fetchKeyPage,
details: { ...current.details, [apiKey]: { status: "failed" } },
};
});
});
},
[detailState, fetchKeyDetail, fetchKeyPage],
);
return (
<div className="space-y-4">
<div className="mt-2 flex items-center gap-3">
{summaryLoading ? (
<ChartLoader />
) : (
<ActivityMetrics
modelMetrics={{}}
summaryMetrics={summary}
hidePromptCachingMetrics={hidePromptCachingMetrics}
/>
)}
<div className="mt-2 flex flex-wrap items-center gap-3">
<InputGroup className="max-w-md">
<InputGroupAddon>
<Search className="size-4 text-muted-foreground" />
@ -30,27 +400,36 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({ keyMetrics, hidePro
aria-label="Search keys"
placeholder="Search by key alias, key hash, user ID, or email"
value={query}
onChange={(e) => setQuery(e.target.value)}
onChange={(event) => updateQuery(event.target.value)}
/>
{isFiltering && (
<InputGroupAddon align="inline-end">
<InputGroupButton size="icon-xs" aria-label="Clear key search" onClick={() => setQuery("")}>
<InputGroupButton size="icon-xs" aria-label="Clear key search" onClick={() => updateQuery("")}>
<X />
</InputGroupButton>
</InputGroupAddon>
)}
</InputGroup>
<span className="text-sm text-muted-foreground">
Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys
</span>
{!pageUnavailable && (
<span className="text-sm text-muted-foreground" aria-live="polite">
{keyCountLabel}
</span>
)}
</div>
{isFiltering && totalKeys > 0 && shownKeys === 0 ? (
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
No keys match &quot;{query.trim()}&quot; in this date range
</p>
) : (
<ActivityMetrics modelMetrics={filtered} hidePromptCachingMetrics={hidePromptCachingMetrics} />
{noMatches ? emptyFilterBody : <div className="rounded-lg border">{listBody}</div>}
{showSearchErrorNote && (
<p className="text-sm text-muted-foreground">Could not search all keys. {searchRetryButton}</p>
)}
{loadingMore && <p className="text-sm text-muted-foreground">Loading more keys...</p>}
{showNextPageRetry && (
<p className="text-sm text-muted-foreground">
Could not load more keys.{" "}
<button type="button" className="font-medium text-foreground underline" onClick={retryNextPage}>
Retry
</button>
</p>
)}
{!isFiltering && <div ref={sentinelRef} aria-hidden="true" className="h-1" />}
</div>
);
};

View file

@ -0,0 +1,33 @@
import type { Team } from "@/components/key_team_helpers/key_list";
import { toKeyMetadata, type KeyActivityRow, type KeySpendActivityRow } from "../dailyActivityApi";
import { formatKeyLabel } from "@/components/activity_metrics";
import type { ModelActivityData } from "../types";
export const keyActivityRowsToMetrics = (
rows: readonly (KeyActivityRow | KeySpendActivityRow)[],
teams: Team[],
): Record<string, ModelActivityData> =>
Object.fromEntries(
rows.map((row) => {
const metadata = toKeyMetadata(row.metadata);
const metrics: ModelActivityData = {
label: formatKeyLabel({ metadata }, row.api_key, teams),
key_metadata: metadata,
total_requests: row.metrics.api_requests,
total_successful_requests: row.metrics.successful_requests,
total_failed_requests: row.metrics.failed_requests,
total_cache_read_input_tokens: row.metrics.cache_read_input_tokens,
total_cache_creation_input_tokens: row.metrics.cache_creation_input_tokens,
total_tokens: row.metrics.total_tokens,
prompt_tokens: row.metrics.prompt_tokens,
completion_tokens: row.metrics.completion_tokens,
total_spend: row.metrics.spend,
total_response_time_ms:
"total_response_time_ms" in row.metrics ? row.metrics.total_response_time_ms : undefined,
total_timed_requests: "timed_requests" in row.metrics ? row.metrics.timed_requests : undefined,
top_models: [],
daily_data: [],
};
return [row.api_key, metrics];
}),
);

View file

@ -0,0 +1,101 @@
import type { components } from "@/lib/http/schema";
import type { BreakdownMetrics, DailyData, KeyMetadata, KeyMetricWithMetadata, MetricWithMetadata } from "./types";
export type DailyActivityEntity = "user" | "team" | "tag" | "organization" | "customer" | "agent";
export type DailyActivityAggregatedResponse = components["schemas"]["SpendAnalyticsPaginatedResponse"];
export type DailyActivityMetadata = components["schemas"]["DailySpendMetadata"];
export type ExportType = components["schemas"]["ExportType"];
export type ExportFormat = "csv" | "json";
export type KeyActivityRow = components["schemas"]["KeyActivityRow"];
export type KeySpendActivityRow = components["schemas"]["KeySpendActivityRow"];
export type DailyActivityKeySearchResponse = components["schemas"]["DailyActivityKeySearchResponse"];
export type DailyActivityKeyPageResponse = components["schemas"]["DailyActivityKeyPageResponse"];
export type ModelTopKeysResponse = components["schemas"]["ModelTopKeysResponse"];
export type CacheLeakageKeysResponse = components["schemas"]["CacheLeakageKeysResponse"];
export interface DailyActivityRequest {
accessToken: string;
startTime: Date;
endTime: Date;
entityIds?: readonly string[] | null;
excludeEntityIds?: readonly string[];
apiKey?: string | null;
model?: string | null;
includeCurrentUtcDay?: boolean;
apiKeyLimit?: number;
}
export const EMPTY_DAILY_ACTIVITY_METADATA: DailyActivityMetadata = {
has_more: false,
page: 1,
total_pages: 1,
total_spend: 0,
total_flat_cost: 0,
total_api_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,
total_tokens: 0,
total_prompt_tokens: 0,
total_completion_tokens: 0,
total_cache_read_input_tokens: 0,
total_cache_creation_input_tokens: 0,
total_compression_saved_tokens: 0,
total_compression_savings_spend: 0,
total_prompt_caching_savings_spend: 0,
total_gateway_injected_caching_savings_spend: 0,
total_autorouter_savings_spend: 0,
total_response_time_ms: 0,
total_timed_requests: 0,
};
export const EMPTY_DAILY_ACTIVITY_RESPONSE: DailyActivityAggregatedResponse = {
results: [],
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
};
type SchemaMetricWithMetadata = components["schemas"]["MetricWithMetadata"];
type SchemaKeyMetricWithMetadata = components["schemas"]["KeyMetricWithMetadata"];
const toKeyMetric = (entry: SchemaKeyMetricWithMetadata): KeyMetricWithMetadata => ({
metrics: entry.metrics,
metadata: toKeyMetadata(entry.metadata),
});
export const toKeyMetadata = (metadata: components["schemas"]["KeyMetadata"] | undefined): KeyMetadata => ({
key_alias: metadata?.key_alias ?? null,
team_id: metadata?.team_id ?? null,
user_id: metadata?.user_id,
user_email: metadata?.user_email,
key_exists: metadata?.key_exists,
});
const toMetric = (entry: SchemaMetricWithMetadata): MetricWithMetadata => ({
metrics: entry.metrics,
metadata: entry.metadata ?? {},
api_key_breakdown: Object.fromEntries(
Object.entries(entry.api_key_breakdown ?? {}).map(([key, value]) => [key, toKeyMetric(value)]),
),
});
const toMetricMap = (
map: { [key: string]: SchemaMetricWithMetadata } | undefined,
): { [key: string]: MetricWithMetadata } =>
Object.fromEntries(Object.entries(map ?? {}).map(([key, value]) => [key, toMetric(value)]));
export const toDailyData = (response: DailyActivityAggregatedResponse): DailyData[] =>
(response.results ?? []).map((day) => {
const breakdown = day.breakdown;
const normalized: BreakdownMetrics = {
models: toMetricMap(breakdown?.models),
model_groups: toMetricMap(breakdown?.model_groups),
mcp_servers: toMetricMap(breakdown?.mcp_servers),
providers: toMetricMap(breakdown?.providers),
api_keys: Object.fromEntries(
Object.entries(breakdown?.api_keys ?? {}).map(([key, value]) => [key, toKeyMetric(value)]),
),
entities: toMetricMap(breakdown?.entities),
endpoints: toMetricMap(breakdown?.endpoints),
};
return { date: day.date, metrics: day.metrics, breakdown: normalized };
});

View file

@ -0,0 +1,154 @@
import { describe, expect, it } from "vitest";
import type { components } from "@/lib/http/schema";
import {
EMPTY_DAILY_ACTIVITY_METADATA,
type DailyActivityAggregatedResponse,
type KeySpendActivityRow,
toDailyData,
} from "./dailyActivityApi";
import { keyDetailFromResponse, mergeKeyActivityPages, overallUsageMetrics } from "./keyActivityData";
const completeMetrics: components["schemas"]["SpendMetrics"] = {
api_requests: 2,
autorouter_savings_spend: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
completion_tokens: 3,
compression_saved_tokens: 0,
compression_savings_spend: 0,
failed_requests: 0,
flat_cost: 0,
gateway_injected_caching_savings_spend: 0,
prompt_caching_savings_spend: 0,
prompt_tokens: 4,
spend: 1.25,
successful_requests: 2,
timed_requests: 0,
total_response_time_ms: 0,
total_tokens: 7,
};
const apiKeyActivity = {
metrics: completeMetrics,
metadata: { key_alias: "visible-key", team_id: null },
};
const aggregatedResponse: DailyActivityAggregatedResponse = {
metadata: {
...EMPTY_DAILY_ACTIVITY_METADATA,
total_api_requests: 200,
total_successful_requests: 198,
total_failed_requests: 2,
total_tokens: 700,
total_prompt_tokens: 400,
total_completion_tokens: 300,
total_spend: 500,
},
results: [
{
date: "2026-09-27",
metrics: completeMetrics,
breakdown: {
api_keys: { "key-hash": apiKeyActivity },
models: {
"gpt-4o-mini": {
metrics: completeMetrics,
metadata: {},
api_key_breakdown: { "key-hash": apiKeyActivity },
},
},
},
},
],
};
const pageRow = (api_key: string): KeySpendActivityRow => ({
api_key,
metrics: {
api_requests: 1,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
completion_tokens: 2,
failed_requests: 0,
prompt_tokens: 3,
spend: 1,
successful_requests: 1,
total_tokens: 5,
},
metadata: { key_alias: api_key, team_id: null },
});
describe("key activity data", () => {
it("builds overall totals from metadata and daily data from complete daily metrics", () => {
const summary = overallUsageMetrics(
toDailyData(aggregatedResponse),
aggregatedResponse.metadata ?? EMPTY_DAILY_ACTIVITY_METADATA,
);
expect(summary.total_requests).toBe(200);
expect(summary.total_spend).toBe(500);
expect(summary.daily_data).toStrictEqual([
{
date: "2026-09-27",
metrics: {
prompt_tokens: 4,
completion_tokens: 3,
total_tokens: 7,
api_requests: 2,
spend: 1.25,
successful_requests: 2,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
avg_response_time_ms: null,
},
},
]);
});
it("appends pages without duplicate keys and compares the server offset to the total", () => {
const merged = mergeKeyActivityPages(
[pageRow("key-a"), pageRow("key-b")],
[pageRow("key-b"), pageRow("key-c")],
5,
2,
);
expect(merged.rows.map((row) => row.api_key)).toStrictEqual(["key-a", "key-b", "key-c"]);
expect(merged.nextOffset).toBe(4);
expect(merged.hasMore).toBe(true);
expect(mergeKeyActivityPages(merged.rows, [pageRow("key-d")], 5, 4).hasMore).toBe(false);
});
it("advances past a page of already loaded keys and stops only on an empty page", () => {
const current = [pageRow("key-a"), pageRow("key-b")];
const duplicates = mergeKeyActivityPages(current, [pageRow("key-a"), pageRow("key-b")], 5, 2);
expect(duplicates).toEqual({ rows: current, nextOffset: 4, hasMore: true });
expect(mergeKeyActivityPages(current, [], 5, 2)).toEqual({ rows: current, nextOffset: 2, hasMore: false });
});
it("builds full daily details and top models for the requested key", () => {
const detail = keyDetailFromResponse(aggregatedResponse, "key-hash", []);
expect(detail?.total_requests).toBe(2);
expect(detail?.daily_data).toStrictEqual([
{
date: "2026-09-27",
metrics: {
prompt_tokens: 4,
completion_tokens: 3,
total_tokens: 7,
api_requests: 2,
spend: 1.25,
successful_requests: 2,
failed_requests: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
avg_response_time_ms: null,
},
},
]);
expect(detail?.top_models.map((model) => model.model)).toStrictEqual(["gpt-4o-mini"]);
});
});

View file

@ -0,0 +1,61 @@
import type { Team } from "@/components/key_team_helpers/key_list";
import { processActivityData } from "@/components/activity_metrics";
import type { DailyActivityAggregatedResponse, DailyActivityMetadata, KeySpendActivityRow } from "./dailyActivityApi";
import { toDailyData } from "./dailyActivityApi";
import type { ModelActivityData } from "./types";
export const overallUsageMetrics = (
results: ReturnType<typeof toDailyData>,
metadata: DailyActivityMetadata,
): ModelActivityData => ({
label: "Overall Usage",
total_requests: metadata.total_api_requests,
total_successful_requests: metadata.total_successful_requests,
total_failed_requests: metadata.total_failed_requests,
total_cache_read_input_tokens: metadata.total_cache_read_input_tokens,
total_cache_creation_input_tokens: metadata.total_cache_creation_input_tokens,
total_tokens: metadata.total_tokens,
prompt_tokens: metadata.total_prompt_tokens,
completion_tokens: metadata.total_completion_tokens,
total_spend: metadata.total_spend,
total_response_time_ms: metadata.total_response_time_ms,
total_timed_requests: metadata.total_timed_requests,
top_models: [],
daily_data: results.map((day) => ({
date: day.date,
metrics: {
prompt_tokens: day.metrics.prompt_tokens,
completion_tokens: day.metrics.completion_tokens,
total_tokens: day.metrics.total_tokens,
api_requests: day.metrics.api_requests,
spend: day.metrics.spend,
successful_requests: day.metrics.successful_requests,
failed_requests: day.metrics.failed_requests,
cache_read_input_tokens: day.metrics.cache_read_input_tokens,
cache_creation_input_tokens: day.metrics.cache_creation_input_tokens,
avg_response_time_ms:
day.metrics.timed_requests && day.metrics.timed_requests > 0
? (day.metrics.total_response_time_ms ?? 0) / day.metrics.timed_requests
: null,
},
})),
});
export const mergeKeyActivityPages = (
current: readonly KeySpendActivityRow[],
next: readonly KeySpendActivityRow[],
total: number,
offset: number,
): { rows: KeySpendActivityRow[]; nextOffset: number; hasMore: boolean } => {
const rows: KeySpendActivityRow[] = Array.from(
new Map([...current, ...next].map((row) => [row.api_key, row])).values(),
);
const nextOffset = offset + next.length;
return { rows, nextOffset, hasMore: next.length > 0 && nextOffset < total };
};
export const keyDetailFromResponse = (
response: DailyActivityAggregatedResponse,
apiKey: string,
teams: Team[],
): ModelActivityData | undefined => processActivityData({ results: toDailyData(response) }, "api_keys", teams)[apiKey];

View file

@ -16,7 +16,6 @@ function activity(label: string, key_metadata?: KeyMetadata): ModelActivityData
prompt_tokens: 5,
completion_tokens: 5,
total_spend: 0.01,
top_api_keys: [],
top_models: [],
daily_data: [],
};

View file

@ -55,16 +55,6 @@ export interface KeyMetadata {
tags?: { tag: string; usage: number }[];
}
export interface TopApiKeyData {
api_key: string;
key_alias: string | null;
team_id: string | null;
user: string | null;
spend: number;
requests: number;
tokens: number;
}
export interface TopModelData {
model: string;
spend: number;
@ -88,7 +78,6 @@ export interface ModelActivityData {
total_spend: number;
total_response_time_ms?: number;
total_timed_requests?: number;
top_api_keys: TopApiKeyData[];
top_models: TopModelData[];
daily_data: {
date: string;

View file

@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { ActivityMetrics, formatKeyLabel, processActivityData, ResponseTimeTooltip } from "./activity_metrics";
@ -120,7 +120,6 @@ const createMockModelActivityData = (label: string, overrides: Partial<ModelActi
total_spend: 100.5,
total_cache_read_input_tokens: 1000,
total_cache_creation_input_tokens: 500,
top_api_keys: [],
top_models: [],
daily_data: [
{
@ -152,7 +151,6 @@ const GPT_35_MODEL_DATA: ModelActivityData = {
total_spend: 25.25,
total_cache_read_input_tokens: 500,
total_cache_creation_input_tokens: 250,
top_api_keys: [],
top_models: [],
daily_data: [
{
@ -244,88 +242,135 @@ describe("ActivityMetrics", () => {
expect(tokenElements.length).toBeGreaterThan(0);
});
it("should not display Top Virtual Keys section when model has no top_api_keys", () => {
render(<ActivityMetrics modelMetrics={mockModelMetrics} />);
expect(screen.queryByText("Top Virtual Keys by Spend")).not.toBeInTheDocument();
it("only fetches top keys for sections that have been expanded", async () => {
const fetchTopApiKeys = vi.fn().mockResolvedValue({
api_keys: [
{
api_key: "key-123",
metrics: { ...EMPTY_SPEND_METRICS, spend: 50.25, api_requests: 25, total_tokens: 12500 },
metadata: { key_alias: "Test Key", team_id: "team1" },
},
],
});
render(
<ActivityMetrics
modelMetrics={{
"gpt-4": { ...mockModelMetrics["gpt-4"], total_spend: 100 },
"gpt-3.5": { ...GPT_35_MODEL_DATA, total_spend: 10 },
}}
fetchTopApiKeys={fetchTopApiKeys}
/>,
);
expect(await screen.findAllByText("Test Key")).toHaveLength(1);
expect(fetchTopApiKeys).toHaveBeenCalledTimes(1);
expect(fetchTopApiKeys).toHaveBeenCalledWith("gpt-4");
fireEvent.click(screen.getAllByText("GPT-3.5")[0]);
await waitFor(() => expect(fetchTopApiKeys).toHaveBeenCalledWith("gpt-3.5"));
expect(await screen.findAllByText("Test Key")).toHaveLength(2);
expect(fetchTopApiKeys).toHaveBeenCalledTimes(2);
});
it("should display top API keys section when present", () => {
const modelWithTopKeys: Record<string, ModelActivityData> = {
"gpt-4": {
...mockModelMetrics["gpt-4"],
top_api_keys: [
{
api_key: "key-123",
key_alias: "Test Key",
team_id: "team1",
user: "owner@example.com",
spend: 50.25,
requests: 25,
tokens: 12500,
},
{
api_key: "key-456",
key_alias: "Owner Alias",
team_id: null,
user: "Owner Alias",
spend: 40.25,
requests: 20,
tokens: 10000,
},
],
},
};
it("renders the keys the model_top_keys route returns", async () => {
const fetchTopApiKeys = vi.fn().mockResolvedValue({
api_keys: [
{
api_key: "key-123",
metrics: { ...EMPTY_SPEND_METRICS, spend: 50.25, api_requests: 25, total_tokens: 12500 },
metadata: { key_alias: "Test Key", team_id: "team1", user_email: "owner@example.com" },
},
{
api_key: "key-456",
metrics: { ...EMPTY_SPEND_METRICS, spend: 40.25, api_requests: 20, total_tokens: 10000 },
metadata: { key_alias: "Owner Alias", team_id: null, user_id: "Owner Alias" },
},
{
api_key: "key-7890123456",
metrics: { ...EMPTY_SPEND_METRICS, spend: 30, api_requests: 10, total_tokens: 5000 },
metadata: { key_alias: null, team_id: null, user_id: "owner-id-3" },
},
],
});
render(<ActivityMetrics modelMetrics={mockModelMetrics} fetchTopApiKeys={fetchTopApiKeys} />);
render(<ActivityMetrics modelMetrics={modelWithTopKeys} />);
expect(screen.getByText("Top Virtual Keys by Spend")).toBeInTheDocument();
expect(screen.getByText("Test Key")).toBeInTheDocument();
expect(await screen.findByText("Top Virtual Keys by Spend")).toBeInTheDocument();
expect(await screen.findByText("Test Key")).toBeInTheDocument();
expect(screen.getByText(/Team: team1/)).toBeInTheDocument();
expect(screen.getByText("User: owner@example.com")).toBeInTheDocument();
expect(screen.getByText("Owner Alias")).toBeInTheDocument();
expect(screen.queryByText("User: Owner Alias")).not.toBeInTheDocument();
expect(screen.getByText(/key-789012/)).toBeInTheDocument();
expect(screen.getByText("User: owner-id-3")).toBeInTheDocument();
expect(fetchTopApiKeys).toHaveBeenCalledTimes(1);
});
it("should display API key hash when alias is missing", () => {
const modelWithTopKeys: Record<string, ModelActivityData> = {
"gpt-4": {
...mockModelMetrics["gpt-4"],
top_api_keys: [
{
api_key: "key-1234567890",
key_alias: null,
team_id: null,
user: null,
spend: 50.25,
requests: 25,
tokens: 12500,
},
],
},
};
it("refetches and shows loading again when the fetcher identity changes on an expanded model", async () => {
const firstFetch = vi.fn().mockResolvedValue({
api_keys: [
{
api_key: "key-old",
metrics: { ...EMPTY_SPEND_METRICS, spend: 10, api_requests: 5, total_tokens: 100 },
metadata: { key_alias: "Old Scope Key", team_id: null },
},
],
});
const secondFetch = vi.fn().mockResolvedValue({
api_keys: [
{
api_key: "key-new",
metrics: { ...EMPTY_SPEND_METRICS, spend: 20, api_requests: 7, total_tokens: 200 },
metadata: { key_alias: "New Scope Key", team_id: null },
},
],
});
const { rerender } = render(<ActivityMetrics modelMetrics={mockModelMetrics} fetchTopApiKeys={firstFetch} />);
render(<ActivityMetrics modelMetrics={modelWithTopKeys} />);
expect(screen.getByText(/key-123456/)).toBeInTheDocument();
expect(await screen.findByText("Old Scope Key")).toBeInTheDocument();
expect(firstFetch).toHaveBeenCalledWith("gpt-4");
rerender(<ActivityMetrics modelMetrics={mockModelMetrics} fetchTopApiKeys={secondFetch} />);
expect(screen.getByText("Loading top keys...")).toBeInTheDocument();
expect(screen.queryByText("Old Scope Key")).not.toBeInTheDocument();
expect(await screen.findByText("New Scope Key")).toBeInTheDocument();
expect(secondFetch).toHaveBeenCalledWith("gpt-4");
});
it("should display team information for top API keys", () => {
const modelWithTopKeys: Record<string, ModelActivityData> = {
"gpt-4": {
...mockModelMetrics["gpt-4"],
top_api_keys: [
it("shows an error with Retry when the top keys request fails and refetches on retry", async () => {
const fetchTopApiKeys = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce({
api_keys: [
{
api_key: "key-123",
key_alias: "Test Key",
team_id: "team1",
user: null,
spend: 50.25,
requests: 25,
tokens: 12500,
api_key: "key-retry",
metrics: { ...EMPTY_SPEND_METRICS, spend: 5, api_requests: 1, total_tokens: 10 },
metadata: { key_alias: "Retried Key", team_id: null },
},
],
},
};
});
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
render(<ActivityMetrics modelMetrics={mockModelMetrics} fetchTopApiKeys={fetchTopApiKeys} />);
render(<ActivityMetrics modelMetrics={modelWithTopKeys} />);
expect(screen.getByText(/Team: team1/)).toBeInTheDocument();
expect(await screen.findByText(/Could not load top keys\./)).toBeInTheDocument();
expect(screen.getByText("Top Virtual Keys by Spend")).toBeInTheDocument();
expect(fetchTopApiKeys).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(screen.getByText("Loading top keys...")).toBeInTheDocument();
expect(await screen.findByText("Retried Key")).toBeInTheDocument();
expect(fetchTopApiKeys).toHaveBeenCalledTimes(2);
expect(fetchTopApiKeys).toHaveBeenLastCalledWith("gpt-4");
expect(screen.queryByText(/Could not load top keys\./)).not.toBeInTheDocument();
consoleError.mockRestore();
});
it("hides the top keys section without a fetcher", () => {
render(<ActivityMetrics modelMetrics={mockModelMetrics} />);
expect(screen.queryByText("Top Virtual Keys by Spend")).not.toBeInTheDocument();
});
it("should display Model Usage when model has top_models", () => {
@ -386,7 +431,6 @@ describe("ActivityMetrics", () => {
render(<ActivityMetrics modelMetrics={multipleModels} />);
// Only the highest-spend section is expanded initially, so only its body is mounted.
const sectionsMounted = () => screen.getAllByText("Spend per day").length;
expect(sectionsMounted()).toBe(1);
@ -788,8 +832,6 @@ describe("processActivityData", () => {
expect(Object.keys(result).sort()).toEqual(["gpt-5.2", "gpt-5.2-eu"]);
expect(result["gpt-5.2-eu"].label).toBe("gpt-5.2-eu");
expect(result["gpt-5.2-eu"].total_spend).toBe(7);
expect(result["gpt-5.2-eu"].top_api_keys).toHaveLength(1);
expect(result["gpt-5.2-eu"].top_api_keys[0].key_alias).toBe("eu-key");
expect(result["gpt-5.2"].total_spend).toBe(3);
expect(result["gpt-5.2"].total_requests).toBe(3);
});
@ -1107,151 +1149,6 @@ describe("processActivityData", () => {
};
const result = processActivityData(dailyActivityWithBreakdown, "models");
expect(result["gpt-4"].top_api_keys).toHaveLength(2);
expect(result["gpt-4"].top_api_keys[0].spend).toBe(60.0);
expect(result["gpt-4"].top_api_keys[0].api_key).toBe("key-1");
expect(result["gpt-4"].top_api_keys[1].spend).toBe(40.5);
expect(result["gpt-4"].top_api_keys.map(({ api_key, user }) => [api_key, user])).toEqual([
["key-1", "owner-1@example.com"],
["key-2", "owner-id-2"],
]);
});
it("should limit top_api_keys to 5 entries", () => {
const dailyActivityWithManyKeys: { results: DailyData[] } = {
results: [
{
date: "2025-01-01",
metrics: {
spend: 100.5,
prompt_tokens: 30000,
completion_tokens: 20000,
total_tokens: 50000,
api_requests: 100,
successful_requests: 95,
failed_requests: 5,
cache_read_input_tokens: 1000,
cache_creation_input_tokens: 500,
},
breakdown: {
models: {
"gpt-4": {
metrics: {
spend: 100.5,
prompt_tokens: 30000,
completion_tokens: 20000,
total_tokens: 50000,
api_requests: 100,
successful_requests: 95,
failed_requests: 5,
cache_read_input_tokens: 1000,
cache_creation_input_tokens: 500,
},
metadata: {},
api_key_breakdown: {
"key-1": {
metrics: {
spend: 20.0,
prompt_tokens: 6000,
completion_tokens: 4000,
total_tokens: 10000,
api_requests: 20,
successful_requests: 19,
failed_requests: 1,
cache_read_input_tokens: 200,
cache_creation_input_tokens: 100,
},
metadata: { key_alias: "key-1", team_id: null },
},
"key-2": {
metrics: {
spend: 19.0,
prompt_tokens: 5700,
completion_tokens: 3800,
total_tokens: 9500,
api_requests: 19,
successful_requests: 18,
failed_requests: 1,
cache_read_input_tokens: 190,
cache_creation_input_tokens: 95,
},
metadata: { key_alias: "key-2", team_id: null },
},
"key-3": {
metrics: {
spend: 18.0,
prompt_tokens: 5400,
completion_tokens: 3600,
total_tokens: 9000,
api_requests: 18,
successful_requests: 17,
failed_requests: 1,
cache_read_input_tokens: 180,
cache_creation_input_tokens: 90,
},
metadata: { key_alias: "key-3", team_id: null },
},
"key-4": {
metrics: {
spend: 17.0,
prompt_tokens: 5100,
completion_tokens: 3400,
total_tokens: 8500,
api_requests: 17,
successful_requests: 16,
failed_requests: 1,
cache_read_input_tokens: 170,
cache_creation_input_tokens: 85,
},
metadata: { key_alias: "key-4", team_id: null },
},
"key-5": {
metrics: {
spend: 16.0,
prompt_tokens: 4800,
completion_tokens: 3200,
total_tokens: 8000,
api_requests: 16,
successful_requests: 15,
failed_requests: 1,
cache_read_input_tokens: 160,
cache_creation_input_tokens: 80,
},
metadata: { key_alias: "key-5", team_id: null },
},
"key-6": {
metrics: {
spend: 15.0,
prompt_tokens: 4500,
completion_tokens: 3000,
total_tokens: 7500,
api_requests: 15,
successful_requests: 14,
failed_requests: 1,
cache_read_input_tokens: 150,
cache_creation_input_tokens: 75,
},
metadata: { key_alias: "key-6", team_id: null },
},
},
},
},
model_groups: {},
mcp_servers: {},
providers: {},
api_keys: {},
entities: {},
},
},
],
};
const result = processActivityData(dailyActivityWithManyKeys, "models");
expect(result["gpt-4"].top_api_keys).toHaveLength(5);
expect(result["gpt-4"].top_api_keys[0].spend).toBe(20.0);
expect(result["gpt-4"].top_api_keys[4].spend).toBe(16.0);
});
it("should return empty object when results array is empty", () => {
@ -1368,8 +1265,6 @@ describe("processActivityData", () => {
};
const result = processActivityData(dailyActivityWithBreakdown, "api_keys", MOCK_TEAMS);
expect(result["key-1"].top_api_keys).toEqual([]);
});
it("should handle missing cache tokens gracefully", () => {

View file

@ -13,16 +13,20 @@ import { resolveTeamAliasFromTeamID } from "@/utils/teamUtils";
import { Card, CardContent } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";
import React, { useState } from "react";
import React, { useRef, useState } from "react";
import { Team } from "./key_team_helpers/key_list";
import KeyModelUsageView from "./UsagePage/components/KeyModelUsageView";
import { keyActivityLabel } from "./UsagePage/keyActivityLabel";
import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopApiKeyData, TopModelData } from "./UsagePage/types";
import type { ModelTopKeysResponse } from "./UsagePage/dailyActivityApi";
import { DailyData, KeyMetricWithMetadata, ModelActivityData, TopModelData } from "./UsagePage/types";
import { averageResponseTimeMs, formatResponseTime, valueFormatter } from "./UsagePage/utils/value_formatters";
interface ActivityMetricsProps {
modelMetrics: Record<string, ModelActivityData>;
summaryMetrics?: ModelActivityData;
summaryTitle?: string;
hidePromptCachingMetrics?: boolean;
fetchTopApiKeys?: (model: string) => Promise<ModelTopKeysResponse>;
}
const modelAverageResponseTimeMs = (metrics: ModelActivityData): number | null =>
@ -37,14 +41,142 @@ export const ResponseTimeTooltip = ({ active, payload, label }: ChartTooltipProp
/>
);
const ModelSection = ({
const ModelTopKeys = ({
modelName,
fetchTopApiKeys,
}: {
modelName: string;
fetchTopApiKeys: (model: string) => Promise<ModelTopKeysResponse>;
}) => {
interface ModelTopKeyRow {
api_key: string;
key_alias: string | null;
team_id: string | null;
user: string | null;
spend: number;
requests: number;
tokens: number;
}
const [settled, setSettled] = useState<{
modelName: string;
fetchTopApiKeys: (model: string) => Promise<ModelTopKeysResponse>;
rows: ModelTopKeyRow[];
failed: boolean;
} | null>(null);
const [retryToken, setRetryToken] = useState(0);
React.useEffect(() => {
let cancelled = false;
fetchTopApiKeys(modelName)
.then((response) => {
if (cancelled) return;
setSettled({
modelName,
fetchTopApiKeys,
rows: response.api_keys.map((row) => ({
api_key: row.api_key,
key_alias: row.metadata.key_alias ?? null,
team_id: row.metadata.team_id ?? null,
user: row.metadata.user_email ?? row.metadata.user_id ?? null,
spend: row.metrics.spend,
requests: row.metrics.api_requests,
tokens: row.metrics.total_tokens,
})),
failed: false,
});
})
.catch((error) => {
if (cancelled) return;
console.error(`Failed to fetch top keys for ${modelName}:`, error);
setSettled({ modelName, fetchTopApiKeys, rows: [], failed: true });
});
return () => {
cancelled = true;
};
}, [modelName, fetchTopApiKeys, retryToken]);
const current = settled?.modelName === modelName && settled.fetchTopApiKeys === fetchTopApiKeys ? settled : null;
if (current === null) {
return (
<Card className="mt-4">
<CardContent>
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys by Spend</h3>
<p className="mt-3 text-sm text-muted-foreground">Loading top keys...</p>
</CardContent>
</Card>
);
}
if (current.failed) {
return (
<Card className="mt-4">
<CardContent>
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys by Spend</h3>
<p className="mt-3 text-sm text-muted-foreground">
Could not load top keys.{" "}
<button
type="button"
className="font-medium text-foreground underline"
onClick={() => {
setSettled(null);
setRetryToken((token) => token + 1);
}}
>
Retry
</button>
</p>
</CardContent>
</Card>
);
}
const rows = current.rows;
if (rows.length === 0) return null;
return (
<Card className="mt-4">
<CardContent>
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys by Spend</h3>
<div className="mt-3">
<div className="grid grid-cols-1 gap-2">
{rows.map((keyData) => {
const keyLabel = keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`;
return (
<div key={keyData.api_key} className="flex justify-between items-center p-3 bg-muted rounded-lg">
<div>
<p className="font-medium">{keyLabel}</p>
{keyData.team_id && <p className="text-xs text-muted-foreground">Team: {keyData.team_id}</p>}
{keyData.user && keyData.user !== keyLabel && (
<p className="text-xs text-muted-foreground">User: {keyData.user}</p>
)}
</div>
<div className="text-right">
<p className="font-medium">${formatNumberWithCommas(keyData.spend, 2)}</p>
<p className="text-xs text-muted-foreground">
{keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens
</p>
</div>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
);
};
export const ModelSection = ({
modelName,
metrics,
hidePromptCachingMetrics = false,
fetchTopApiKeys,
}: {
modelName: string;
metrics: ModelActivityData;
hidePromptCachingMetrics?: boolean;
fetchTopApiKeys?: (model: string) => Promise<ModelTopKeysResponse>;
}) => {
return (
<div className="space-y-2">
@ -96,37 +228,7 @@ const ModelSection = ({
</Card>
</div>
{metrics.top_api_keys && metrics.top_api_keys.length > 0 && (
<Card className="mt-4">
<CardContent>
<h3 className="text-lg font-medium text-foreground">Top Virtual Keys by Spend</h3>
<div className="mt-3">
<div className="grid grid-cols-1 gap-2">
{metrics.top_api_keys.map((keyData) => {
const keyLabel = keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`;
return (
<div key={keyData.api_key} className="flex justify-between items-center p-3 bg-muted rounded-lg">
<div>
<p className="font-medium">{keyLabel}</p>
{keyData.team_id && <p className="text-xs text-muted-foreground">Team: {keyData.team_id}</p>}
{keyData.user && keyData.user !== keyLabel && (
<p className="text-xs text-muted-foreground">User: {keyData.user}</p>
)}
</div>
<div className="text-right">
<p className="font-medium">${formatNumberWithCommas(keyData.spend, 2)}</p>
<p className="text-xs text-muted-foreground">
{keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens
</p>
</div>
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
)}
{fetchTopApiKeys && <ModelTopKeys modelName={modelName} fetchTopApiKeys={fetchTopApiKeys} />}
{metrics.top_models && metrics.top_models.length > 0 && <KeyModelUsageView topModels={metrics.top_models} />}
@ -272,24 +374,33 @@ const ModelSection = ({
);
};
const ModelCollapsible = ({
export const ModelCollapsible = ({
defaultOpen,
header,
children,
onFirstOpen,
}: {
defaultOpen: boolean;
header: React.ReactNode;
children: React.ReactNode;
onFirstOpen?: () => void;
}) => {
const [open, setOpen] = useState(defaultOpen);
const [everOpened, setEverOpened] = useState(defaultOpen);
const firstOpenRef = useRef(defaultOpen);
return (
<Collapsible
open={open}
onOpenChange={(next: boolean) => {
setOpen(next);
if (next) setEverOpened(true);
if (next) {
setEverOpened(true);
if (!firstOpenRef.current) {
firstOpenRef.current = true;
onFirstOpen?.();
}
}
}}
className="border-b last:border-b-0"
>
@ -306,7 +417,13 @@ const ModelCollapsible = ({
);
};
export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics, hidePromptCachingMetrics = false }) => {
export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({
modelMetrics,
summaryMetrics,
summaryTitle = "Overall Usage",
hidePromptCachingMetrics = false,
fetchTopApiKeys,
}) => {
const modelNames = Object.keys(modelMetrics).sort((a, b) => {
if (a === "") return 1;
if (b === "") return -1;
@ -374,42 +491,44 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics,
});
// Convert daily_data object to array and sort by date
const sortedDailyData = Object.entries(totalMetrics.daily_data)
.map(([date, metrics]) => ({ date, metrics }))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const sortedDailyData =
summaryMetrics?.daily_data ??
Object.entries(totalMetrics.daily_data)
.map(([date, metrics]) => ({ date, metrics }))
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const totalRequests = summaryMetrics?.total_requests ?? totalMetrics.total_requests;
const totalSuccessfulRequests = summaryMetrics?.total_successful_requests ?? totalMetrics.total_successful_requests;
const totalTokens = summaryMetrics?.total_tokens ?? totalMetrics.total_tokens;
const totalSpend = summaryMetrics?.total_spend ?? totalMetrics.total_spend;
return (
<div className="space-y-8">
{/* Global Summary */}
<div className="border rounded-lg p-4">
<h3 className="text-lg font-medium text-foreground">Overall Usage</h3>
<h3 className="text-lg font-medium text-foreground">{summaryTitle}</h3>
<div className="grid grid-cols-4 gap-4 mb-4">
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Requests</p>
<h3 className="text-lg font-medium text-foreground">{totalMetrics.total_requests.toLocaleString()}</h3>
<h3 className="text-lg font-medium text-foreground">{totalRequests.toLocaleString()}</h3>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Successful Requests</p>
<h3 className="text-lg font-medium text-foreground">
{totalMetrics.total_successful_requests.toLocaleString()}
</h3>
<h3 className="text-lg font-medium text-foreground">{totalSuccessfulRequests.toLocaleString()}</h3>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Tokens</p>
<h3 className="text-lg font-medium text-foreground">{totalMetrics.total_tokens.toLocaleString()}</h3>
<h3 className="text-lg font-medium text-foreground">{totalTokens.toLocaleString()}</h3>
</CardContent>
</Card>
<Card>
<CardContent>
<p className="text-sm text-muted-foreground">Total Spend</p>
<h3 className="text-lg font-medium text-foreground">
${formatNumberWithCommas(totalMetrics.total_spend, 2)}
</h3>
<h3 className="text-lg font-medium text-foreground">${formatNumberWithCommas(totalSpend, 2)}</h3>
</CardContent>
</Card>
</div>
@ -463,40 +582,49 @@ export const ActivityMetrics: React.FC<ActivityMetricsProps> = ({ modelMetrics,
</div>
{/* Individual Model Sections */}
<div className="rounded-lg border">
{modelNames.map((modelName) => (
<ModelCollapsible
key={modelName}
defaultOpen={modelName === modelNames[0]}
header={
<div className="flex justify-between items-center w-full">
<h3 className="text-lg font-medium text-foreground">
{modelMetrics[modelName].label || "Unknown Item"}
</h3>
<div className="flex space-x-4 text-sm text-muted-foreground">
<span>${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)}</span>
<span>{modelMetrics[modelName].total_requests.toLocaleString()} requests</span>
{modelAverageResponseTimeMs(modelMetrics[modelName]) != null && (
<span>{formatResponseTime(modelAverageResponseTimeMs(modelMetrics[modelName]))} avg response</span>
)}
{modelNames.length > 0 && (
<div className="rounded-lg border">
{modelNames.map((modelName) => (
<ModelCollapsible
key={modelName}
defaultOpen={modelName === modelNames[0]}
header={
<div className="flex justify-between items-center w-full">
<h3 className="text-lg font-medium text-foreground">
{modelMetrics[modelName].label || "Unknown Item"}
</h3>
<div className="flex space-x-4 text-sm text-muted-foreground">
<span>${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)}</span>
<span>{modelMetrics[modelName].total_requests.toLocaleString()} requests</span>
{modelAverageResponseTimeMs(modelMetrics[modelName]) != null && (
<span>
{formatResponseTime(modelAverageResponseTimeMs(modelMetrics[modelName]))} avg response
</span>
)}
</div>
</div>
</div>
}
>
<ModelSection
modelName={modelName || "Unknown Model"}
metrics={modelMetrics[modelName]}
hidePromptCachingMetrics={hidePromptCachingMetrics}
/>
</ModelCollapsible>
))}
</div>
}
>
<ModelSection
modelName={modelName || "Unknown Model"}
metrics={modelMetrics[modelName]}
hidePromptCachingMetrics={hidePromptCachingMetrics}
fetchTopApiKeys={fetchTopApiKeys}
/>
</ModelCollapsible>
))}
</div>
)}
</div>
);
};
// Helper function to format key label
export const formatKeyLabel = (modelData: KeyMetricWithMetadata, model: string, teams: Team[]): string => {
export const formatKeyLabel = (
modelData: Pick<KeyMetricWithMetadata, "metadata">,
model: string,
teams: Team[],
): string => {
const keyAlias = keyActivityLabel(modelData.metadata, `key-hash-${model}`);
const teamId = modelData.metadata.team_id;
if (teamId) {
@ -536,7 +664,6 @@ export const processActivityData = (
total_cache_creation_input_tokens: 0,
total_response_time_ms: 0,
total_timed_requests: 0,
top_api_keys: [],
top_models: [],
daily_data: [],
};
@ -576,42 +703,6 @@ export const processActivityData = (
});
});
// Process Virtual Key breakdowns for each metric (skip if key is 'api_keys' to avoid duplication)
if (key !== "api_keys") {
Object.entries(modelMetrics).forEach(([model, _]) => {
const apiKeyBreakdown: Record<string, TopApiKeyData> = {};
// Aggregate Virtual Key data across all days
dailyActivity.results.forEach((day) => {
const modelData = day.breakdown[key]?.[model];
if (modelData && "api_key_breakdown" in modelData) {
Object.entries(modelData.api_key_breakdown || {}).forEach(([apiKey, keyData]) => {
if (!apiKeyBreakdown[apiKey]) {
apiKeyBreakdown[apiKey] = {
api_key: apiKey,
key_alias: keyActivityLabel(keyData.metadata, "") || null,
team_id: keyData.metadata.team_id,
user: keyData.metadata.user_email ?? keyData.metadata.user_id ?? null,
spend: 0,
requests: 0,
tokens: 0,
};
}
apiKeyBreakdown[apiKey].spend += keyData.metrics.spend;
apiKeyBreakdown[apiKey].requests += keyData.metrics.api_requests;
apiKeyBreakdown[apiKey].tokens += keyData.metrics.total_tokens;
});
}
});
// Sort by spend and take top 5
modelMetrics[model].top_api_keys = Object.values(apiKeyBreakdown)
.sort((a, b) => b.spend - a.spend)
.slice(0, 5);
});
}
// Process Model breakdowns for each API key (only when key is 'api_keys')
if (key === "api_keys") {
Object.entries(modelMetrics).forEach(([apiKeyHash, _]) => {

View file

@ -3,7 +3,7 @@
import React, { useState } from "react";
import { BarChart3 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { userDailyActivityAggregatedCall } from "../networking";
import { dailyActivityAggregatedCall } from "../networking";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
@ -95,7 +95,15 @@ const UsagePanel: React.FC<Props> = ({ accessToken, userId }) => {
const { data, isLoading } = useQuery({
queryKey: [USAGE_QUERY_KEY, accessToken, userId, timeRange],
queryFn: () => userDailyActivityAggregatedCall(accessToken, start, end, userId),
queryFn: () => {
const request = {
accessToken,
startTime: start,
endTime: end,
entityIds: userId ? [userId] : null,
};
return dailyActivityAggregatedCall("user", request);
},
enabled: !!accessToken,
});

View file

@ -0,0 +1,224 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
cacheLeakageKeysCall,
dailyActivityAggregatedCall,
dailyActivityExportCall,
dailyActivityKeySearchCall,
dailyActivityModelTopKeysCall,
} from "./networking";
import type { DailyActivityEntity, DailyActivityRequest } from "./UsagePage/dailyActivityApi";
const originalFetch = global.fetch;
const captureFetch = () => {
const mockFetch = vi.fn<typeof fetch>().mockImplementation(
async () =>
new Response(JSON.stringify({ results: [], metadata: {}, api_keys: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
global.fetch = mockFetch;
return mockFetch;
};
const requestedUrl = (mockFetch: ReturnType<typeof captureFetch>, callIndex = 0): URL =>
new URL(String(mockFetch.mock.calls[callIndex][0]), "http://example.com");
const start = new Date("2025-01-05T00:00:00Z");
const end = new Date("2025-01-31T00:00:00Z");
const req = (overrides: Partial<DailyActivityRequest> = {}): DailyActivityRequest => ({
accessToken: "sk-key",
startTime: start,
endTime: end,
...overrides,
});
afterEach(() => {
global.fetch = originalFetch;
});
describe("dailyActivityAggregatedCall", () => {
it.each<[DailyActivityEntity, string]>([
["user", "user_id"],
["team", "team_ids"],
["tag", "tags"],
["organization", "organization_ids"],
["customer", "end_user_ids"],
["agent", "agent_ids"],
])("GETs /%s/daily/activity/aggregated with %s filters", async (entity, param) => {
const mockFetch = captureFetch();
await dailyActivityAggregatedCall(entity, req({ entityIds: ["e1", "e2"] }));
const url = requestedUrl(mockFetch);
expect(url.pathname).toBe(`/${entity}/daily/activity/aggregated`);
const expected = entity === "user" ? "e1" : "e1,e2";
expect(url.searchParams.get(param)).toBe(expected);
expect(url.searchParams.get("start_date")).toBe("2025-01-05");
expect(url.searchParams.get("end_date")).toBe("2025-01-31");
expect(url.searchParams.has("timezone")).toBe(true);
expect(url.searchParams.has("page")).toBe(false);
expect(url.searchParams.has("page_size")).toBe(false);
});
it("omits the entity filter when no ids are given", async () => {
const mockFetch = captureFetch();
await dailyActivityAggregatedCall("team", req({ entityIds: null }));
const url = requestedUrl(mockFetch);
expect(url.searchParams.has("team_ids")).toBe(false);
expect(url.searchParams.has("exclude_team_ids")).toBe(false);
});
it.each<[DailyActivityEntity, string]>([
["team", "exclude_team_ids"],
["organization", "exclude_organization_ids"],
["customer", "exclude_end_user_ids"],
["agent", "exclude_agent_ids"],
])("sends exclude ids comma-joined under %s", async (entity, param) => {
const mockFetch = captureFetch();
await dailyActivityAggregatedCall(
entity,
req({ entityIds: ["e1"], excludeEntityIds: ["litellm-dashboard", "other"] }),
);
expect(requestedUrl(mockFetch).searchParams.get(param)).toBe("litellm-dashboard,other");
});
it.each<[DailyActivityEntity]>([["tag"], ["user"]])("emits no exclude param for %s", async (entity) => {
const mockFetch = captureFetch();
await dailyActivityAggregatedCall(entity, req({ entityIds: ["e1"], excludeEntityIds: ["litellm-dashboard"] }));
const params = [...requestedUrl(mockFetch).searchParams.keys()];
expect(params.some((key) => key.startsWith("exclude_"))).toBe(false);
});
it("keeps an empty api_key as a filter rather than widening the read", async () => {
const mockFetch = captureFetch();
await dailyActivityAggregatedCall("user", req({ apiKey: "" }));
expect(requestedUrl(mockFetch).searchParams.get("api_key")).toBe("");
});
it("omits api_key entirely when none is given", async () => {
const mockFetch = captureFetch();
await dailyActivityAggregatedCall("user", req({ apiKey: null }));
expect(requestedUrl(mockFetch).searchParams.has("api_key")).toBe(false);
});
it("sends include_current_utc_day only when set", async () => {
const mockFetch = captureFetch();
await dailyActivityAggregatedCall("user", req({ includeCurrentUtcDay: true }));
expect(requestedUrl(mockFetch).searchParams.get("include_current_utc_day")).toBe("true");
});
it("sends api_key_limit only when provided", async () => {
const mockFetch = captureFetch();
await dailyActivityAggregatedCall("user", req());
await dailyActivityAggregatedCall("user", req({ apiKeyLimit: 250 }));
expect(requestedUrl(mockFetch, 0).searchParams.has("api_key_limit")).toBe(false);
expect(requestedUrl(mockFetch, 1).searchParams.get("api_key_limit")).toBe("250");
});
});
describe("dailyActivityKeySearchCall", () => {
it("GETs the search route with the search term", async () => {
const mockFetch = captureFetch();
await dailyActivityKeySearchCall("team", req({ entityIds: ["t1"] }), "alice");
const url = requestedUrl(mockFetch);
expect(url.pathname).toBe("/team/daily/activity/aggregated/search");
expect(url.searchParams.get("search")).toBe("alice");
expect(url.searchParams.get("team_ids")).toBe("t1");
expect(url.searchParams.has("limit")).toBe(false);
});
it("sends the optional limit", async () => {
const mockFetch = captureFetch();
await dailyActivityKeySearchCall("team", req(), "alice", 50);
expect(requestedUrl(mockFetch).searchParams.get("limit")).toBe("50");
});
});
describe("dailyActivityModelTopKeysCall", () => {
it("GETs model_top_keys with model_group and by_model_group", async () => {
const mockFetch = captureFetch();
await dailyActivityModelTopKeysCall("user", req(), "gpt-4o", true);
const url = requestedUrl(mockFetch);
expect(url.pathname).toBe("/user/daily/activity/aggregated/model_top_keys");
expect(url.searchParams.get("model_group")).toBe("gpt-4o");
expect(url.searchParams.get("by_model_group")).toBe("true");
expect(url.searchParams.has("limit")).toBe(false);
});
it("sends by_model_group=false for the models view", async () => {
const mockFetch = captureFetch();
await dailyActivityModelTopKeysCall("user", req(), "gpt-4o", false);
expect(requestedUrl(mockFetch).searchParams.get("by_model_group")).toBe("false");
});
it("sends the optional limit", async () => {
const mockFetch = captureFetch();
await dailyActivityModelTopKeysCall("user", req(), "gpt-4o", true, 25);
expect(requestedUrl(mockFetch).searchParams.get("limit")).toBe("25");
});
});
describe("dailyActivityExportCall", () => {
it("GETs the export route with export_type and format", async () => {
const mockFetch = captureFetch();
await dailyActivityExportCall("organization", req({ entityIds: ["o1"] }), "daily_with_keys", "csv");
const url = requestedUrl(mockFetch);
expect(url.pathname).toBe("/organization/daily/activity/export");
expect(url.searchParams.get("export_type")).toBe("daily_with_keys");
expect(url.searchParams.get("format")).toBe("csv");
expect(url.searchParams.get("organization_ids")).toBe("o1");
});
});
describe("cacheLeakageKeysCall", () => {
it("GETs the user cache_leakage_keys route with the user scope", async () => {
const mockFetch = captureFetch();
await cacheLeakageKeysCall(req({ entityIds: ["u1"], apiKey: "hash-1", includeCurrentUtcDay: true }));
const url = requestedUrl(mockFetch);
expect(url.pathname).toBe("/user/daily/activity/aggregated/cache_leakage_keys");
expect(url.searchParams.get("user_id")).toBe("u1");
expect(url.searchParams.get("api_key")).toBe("hash-1");
expect(url.searchParams.get("include_current_utc_day")).toBe("true");
expect(url.searchParams.has("limit")).toBe(false);
});
it("sends the optional limit", async () => {
const mockFetch = captureFetch();
await cacheLeakageKeysCall(req(), 75);
expect(requestedUrl(mockFetch).searchParams.get("limit")).toBe("75");
});
});

View file

@ -157,60 +157,6 @@ describe("modelInfoCall", () => {
});
});
describe("daily activity helpers", () => {
const startTime = new Date("2025-02-12T00:00:00.000Z");
const endTime = new Date("2025-02-19T00:00:00.000Z");
let currentFetch: typeof global.fetch;
const setupSuccessfulFetch = () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue({ data: [] }),
} as any);
global.fetch = mockFetch as any;
return mockFetch;
};
beforeEach(() => {
vi.clearAllMocks();
currentFetch = global.fetch;
});
afterEach(() => {
global.fetch = currentFetch;
});
it("appends tag list when tags argument is provided", async () => {
const mockFetch = setupSuccessfulFetch();
await Networking.tagDailyActivityCall("token", startTime, endTime, 2, ["alpha", "beta"]);
expect(mockFetch).toHaveBeenCalledOnce();
const calledUrl = mockFetch.mock.calls[0][0] as string;
const parsed = new URL(calledUrl, "http://example.com");
expect(parsed.pathname).toBe("/tag/daily/activity");
expect(parsed.searchParams.get("tags")).toBe("alpha,beta");
});
it("always includes exclude_team_ids but only adds team_ids when given", async () => {
const mockFetchWithoutTeams = setupSuccessfulFetch();
await Networking.teamDailyActivityCall("token", startTime, endTime, 1, null);
const urlWithoutTeams = new URL(mockFetchWithoutTeams.mock.calls[0][0] as string, "http://example.com");
expect(urlWithoutTeams.searchParams.get("exclude_team_ids")).toBe("litellm-dashboard");
expect(urlWithoutTeams.searchParams.has("team_ids")).toBe(false);
const mockFetchWithTeams = setupSuccessfulFetch();
await Networking.teamDailyActivityCall("token", startTime, endTime, 3, ["team-a", "team-b"]);
const urlWithTeams = new URL(mockFetchWithTeams.mock.calls[0][0] as string, "http://example.com");
expect(urlWithTeams.searchParams.get("team_ids")).toBe("team-a,team-b");
expect(urlWithTeams.searchParams.get("exclude_team_ids")).toBe("litellm-dashboard");
});
});
describe("UI config and public endpoints", () => {
const originalFetch = global.fetch;
@ -772,88 +718,6 @@ describe("getAutoRouterClassifierDefaultPromptCall", () => {
});
});
describe("daily activity api_key filter", () => {
const originalFetch = global.fetch;
const captureFetch = () => {
const mockFetch = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ results: [], metadata: {} }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
global.fetch = mockFetch;
return mockFetch;
};
const requestedUrl = (mockFetch: ReturnType<typeof captureFetch>): string => String(mockFetch.mock.calls[0][0]);
const start = new Date("2025-01-01T00:00:00Z");
const end = new Date("2025-01-31T00:00:00Z");
afterEach(() => {
global.fetch = originalFetch;
});
it("sends the key hash as api_key from the paginated caller", async () => {
const mockFetch = captureFetch();
await Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, "hash-abc");
expect(requestedUrl(mockFetch)).toContain("api_key=hash-abc");
});
it("sends the key hash as api_key from the aggregated caller", async () => {
const mockFetch = captureFetch();
await Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, "hash-abc");
expect(requestedUrl(mockFetch)).toContain("api_key=hash-abc");
});
// The two wrappers serialize the same optional filters through different transports, so an
// absent key has to drop the param in both. Dropping it on one side and sending it empty on
// the other would widen a key-scoped read into an unscoped one.
it.each([
["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, null)],
["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, null)],
])("omits api_key entirely from the %s caller when no key is given", async (_label, call) => {
const mockFetch = captureFetch();
await call();
expect(requestedUrl(mockFetch)).not.toContain("api_key");
});
// An empty key must not be coerced into "no filter". Dropping it would turn a key-scoped read
// into a proxy-wide one and report every key's savings as this key's, so both callers send it
// through and let the filter match nothing instead.
it.each([
["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, null, false, "")],
["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, null, false, "")],
])("keeps an empty api_key as a filter rather than widening the %s read", async (_label, call) => {
const mockFetch = captureFetch();
await call();
expect(requestedUrl(mockFetch)).toContain("api_key=");
});
// user_id rides the same two transports and widens the same way, so it gets the same guard.
// The aggregated caller used to drop "" via `||`; without this the two filters could drift
// apart again on one side only.
it.each([
["paginated", () => Networking.userDailyActivityCall("sk-key", start, end, 1, "", false, null)],
["aggregated", () => Networking.userDailyActivityAggregatedCall("sk-key", start, end, "", false, null)],
])("keeps an empty user_id as a filter rather than widening the %s read", async (_label, call) => {
const mockFetch = captureFetch();
await call();
expect(requestedUrl(mockFetch)).toContain("user_id=");
});
});
describe("userListCall search serialization", () => {
const originalFetch = global.fetch;

View file

@ -121,7 +121,19 @@ import {
deriveErrorMessage,
extractProxyErrorMessage,
unwrapProxyErrorMessage,
type QueryParams,
} from "@/lib/http/client";
import type {
CacheLeakageKeysResponse,
DailyActivityAggregatedResponse,
DailyActivityEntity,
DailyActivityKeyPageResponse,
DailyActivityKeySearchResponse,
DailyActivityRequest,
ExportFormat,
ExportType,
ModelTopKeysResponse,
} from "./UsagePage/dailyActivityApi";
import { resolveApiBase } from "@/lib/http/resolveApiBase";
import {
registerAuthHeaderNameGetter,
@ -1281,192 +1293,110 @@ export const transformRequestCall = async (accessToken: string, request: object)
}
};
type DailyActivityQueryValue = string | number | string[] | null | undefined;
const DEFAULT_DAILY_ACTIVITY_PAGE_SIZE = "1000";
const appendDailyActivityQueryParam = (params: URLSearchParams, key: string, value: DailyActivityQueryValue) => {
if (value === null || value === undefined) {
return;
}
if (Array.isArray(value)) {
if (value.length > 0) {
params.append(key, value.join(","));
}
return;
}
params.append(key, `${value}`);
const ENTITY_ID_QUERY_PARAM: Record<DailyActivityEntity, string> = {
user: "user_id",
team: "team_ids",
tag: "tags",
organization: "organization_ids",
customer: "end_user_ids",
agent: "agent_ids",
};
const buildDailyActivityUrl = (
endpoint: string,
startTime: Date,
endTime: Date,
page: number,
extraQueryParams?: Record<string, DailyActivityQueryValue>,
) => {
const resolvedEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`;
const baseUrl = proxyBaseUrl ? `${proxyBaseUrl}${resolvedEndpoint}` : resolvedEndpoint;
const params = new URLSearchParams();
params.append("start_date", formatDate(startTime));
params.append("end_date", formatDate(endTime));
params.append("page_size", DEFAULT_DAILY_ACTIVITY_PAGE_SIZE);
params.append("page", page.toString());
// Send timezone offset so backend can adjust date range for UTC storage
params.append("timezone", new Date().getTimezoneOffset().toString());
if (extraQueryParams) {
Object.entries(extraQueryParams).forEach(([key, value]) => {
appendDailyActivityQueryParam(params, key, value);
});
}
const queryString = params.toString();
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
const EXCLUDE_ENTITY_ID_QUERY_PARAM: Partial<Record<DailyActivityEntity, string>> = {
team: "exclude_team_ids",
organization: "exclude_organization_ids",
customer: "exclude_end_user_ids",
agent: "exclude_agent_ids",
};
type DailyActivityCallOptions = {
accessToken: string;
endpoint: string;
startTime: Date;
endTime: Date;
page?: number;
extraQueryParams?: Record<string, DailyActivityQueryValue>;
const dailyActivityQuery = (
entity: DailyActivityEntity,
req: DailyActivityRequest,
extra: QueryParams = {},
): QueryParams => {
const entityIds = req.entityIds ?? undefined;
const excludeEntityIds = req.excludeEntityIds;
const joinedEntityIds = entityIds && entityIds.length > 0 ? entityIds.join(",") : undefined;
const entityIdValue = entity === "user" ? entityIds?.[0] : joinedEntityIds;
const excludeParam = EXCLUDE_ENTITY_ID_QUERY_PARAM[entity];
return {
start_date: formatDate(req.startTime),
end_date: formatDate(req.endTime),
model: req.model,
api_key: req.apiKey,
[ENTITY_ID_QUERY_PARAM[entity]]: entityIdValue,
...(excludeParam
? { [excludeParam]: excludeEntityIds && excludeEntityIds.length > 0 ? excludeEntityIds.join(",") : undefined }
: {}),
timezone: new Date().getTimezoneOffset().toString(),
include_current_utc_day: entity === "user" && req.includeCurrentUtcDay ? "true" : undefined,
...extra,
};
};
const fetchDailyActivity = async ({
accessToken,
endpoint,
startTime,
endTime,
page = 1,
extraQueryParams,
}: DailyActivityCallOptions) => {
try {
const url = buildDailyActivityUrl(endpoint, startTime, endTime, page, extraQueryParams);
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error(`Failed to fetch daily activity (${endpoint}):`, error);
throw error;
}
};
export const userDailyActivityCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
page: number = 1,
userId: string | null = null,
includeCurrentUtcDay: boolean = false,
apiKey: string | null = null,
) => {
/**
* Get daily user activity on proxy
*/
return fetchDailyActivity({
accessToken,
endpoint: "/user/daily/activity",
startTime,
endTime,
page,
extraQueryParams: {
user_id: userId,
include_current_utc_day: includeCurrentUtcDay ? "true" : undefined,
api_key: apiKey,
},
export const dailyActivityAggregatedCall = (
entity: DailyActivityEntity,
req: DailyActivityRequest,
): Promise<DailyActivityAggregatedResponse> =>
apiClient.get<DailyActivityAggregatedResponse>(`/${entity}/daily/activity/aggregated`, {
accessToken: req.accessToken,
query: dailyActivityQuery(entity, req, req.apiKeyLimit === undefined ? {} : { api_key_limit: req.apiKeyLimit }),
});
};
export const tagDailyActivityCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
page: number = 1,
tags: string[] | null = null,
) => {
/**
* Get daily user activity on proxy
*/
return fetchDailyActivity({
accessToken,
endpoint: "/tag/daily/activity",
startTime,
endTime,
page,
extraQueryParams: {
tags,
},
export const dailyActivityKeyPageCall = (
entity: DailyActivityEntity,
req: DailyActivityRequest,
offset: number,
limit: number,
): Promise<DailyActivityKeyPageResponse> =>
apiClient.get<DailyActivityKeyPageResponse>(`/${entity}/daily/activity/aggregated/keys`, {
accessToken: req.accessToken,
query: dailyActivityQuery(entity, req, { offset, limit }),
});
};
export const teamDailyActivityCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
page: number = 1,
teamIds: string[] | null = null,
) => {
/**
* Get daily user activity on proxy
*/
return fetchDailyActivity({
accessToken,
endpoint: "/team/daily/activity",
startTime,
endTime,
page,
extraQueryParams: {
team_ids: teamIds,
exclude_team_ids: "litellm-dashboard",
},
export const dailyActivityKeySearchCall = (
entity: DailyActivityEntity,
req: DailyActivityRequest,
search: string,
limit?: number,
): Promise<DailyActivityKeySearchResponse> =>
apiClient.get<DailyActivityKeySearchResponse>(`/${entity}/daily/activity/aggregated/search`, {
accessToken: req.accessToken,
query: dailyActivityQuery(entity, req, { search, ...(limit === undefined ? {} : { limit }) }),
});
};
export const teamDailyActivityAggregatedCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
teamIds: string[] | null = null,
) => {
/**
* Get aggregated daily team activity with per-team breakdown (no pagination)
*/
try {
return await apiClient.get(`/team/daily/activity/aggregated`, {
accessToken,
query: {
start_date: formatDate(startTime),
end_date: formatDate(endTime),
timezone: new Date().getTimezoneOffset().toString(),
team_ids: teamIds && teamIds.length > 0 ? teamIds.join(",") : undefined,
exclude_team_ids: "litellm-dashboard",
},
});
} catch (error) {
console.error("Failed to fetch aggregated team daily activity:", error);
throw error;
}
};
export const dailyActivityModelTopKeysCall = (
entity: DailyActivityEntity,
req: DailyActivityRequest,
model: string,
byModelGroup: boolean,
limit?: number,
): Promise<ModelTopKeysResponse> =>
apiClient.get<ModelTopKeysResponse>(`/${entity}/daily/activity/aggregated/model_top_keys`, {
accessToken: req.accessToken,
query: dailyActivityQuery(entity, req, {
model_group: model,
by_model_group: byModelGroup ? "true" : "false",
...(limit === undefined ? {} : { limit }),
}),
});
export const dailyActivityExportCall = (
entity: DailyActivityEntity,
req: DailyActivityRequest,
exportType: ExportType,
format: ExportFormat,
): Promise<Blob> =>
apiClient.getBlob(`/${entity}/daily/activity/export`, {
accessToken: req.accessToken,
query: dailyActivityQuery(entity, req, { export_type: exportType, format }),
});
export const cacheLeakageKeysCall = (req: DailyActivityRequest, limit?: number): Promise<CacheLeakageKeysResponse> =>
apiClient.get<CacheLeakageKeysResponse>(`/user/daily/activity/aggregated/cache_leakage_keys`, {
accessToken: req.accessToken,
query: dailyActivityQuery("user", req, limit === undefined ? {} : { limit }),
});
export type TeamUserSpendResponse = components["schemas"]["TeamUserSpendResponse"];
@ -1485,63 +1415,6 @@ export const teamSpendByUserCall = async (
},
});
export const organizationDailyActivityCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
page: number = 1,
organizationIds: string[] | null = null,
) => {
return fetchDailyActivity({
accessToken,
endpoint: "/organization/daily/activity",
startTime,
endTime,
page,
extraQueryParams: {
organization_ids: organizationIds,
},
});
};
export const customerDailyActivityCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
page: number = 1,
customerIds: string[] | null = null,
) => {
return fetchDailyActivity({
accessToken,
endpoint: "/customer/daily/activity",
startTime,
endTime,
page,
extraQueryParams: {
end_user_ids: customerIds,
},
});
};
export const agentDailyActivityCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
page: number = 1,
agentIds: string[] | null = null,
) => {
return fetchDailyActivity({
accessToken,
endpoint: "/agent/daily/activity",
startTime,
endTime,
page,
extraQueryParams: {
agent_ids: agentIds,
},
});
};
export const getOnboardingCredentials = async (inviteUUID: string) => {
/**
* Get all models on proxy
@ -2571,43 +2444,6 @@ export const keyAliasesCall = async (
}
};
export const userDailyActivityAggregatedCall = async (
accessToken: string,
startTime: Date,
endTime: Date,
...options: [userId?: string | null, includeCurrentUtcDay?: boolean, apiKey?: string | null]
) => {
/**
* Get aggregated daily user activity (no pagination)
*/
const [userId = null, includeCurrentUtcDay = false, apiKey = null] = options;
try {
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
};
return await apiClient.get(`/user/daily/activity/aggregated`, {
accessToken,
query: {
start_date: formatDate(startTime),
end_date: formatDate(endTime),
timezone: new Date().getTimezoneOffset().toString(),
// Passed raw, matching the paginated caller: both serializers drop null and undefined,
// and both keep "". An empty filter must not vanish, or a request scoped to one user or
// key would silently widen into an unscoped, proxy-wide read.
user_id: userId,
include_current_utc_day: includeCurrentUtcDay ? "true" : undefined,
api_key: apiKey,
},
});
} catch (error) {
console.error("Failed to fetch aggregated user daily activity:", error);
throw error;
}
};
export const gatewayDailyActivityCall = async (accessToken: string, startTime: Date, endTime: Date) => {
/**
* Get gateway request counts (SGR) recorded by the proxy middleware.

View file

@ -1,108 +0,0 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import PaginationStatusAlerts from "./PaginationStatusAlerts";
describe("PaginationStatusAlerts", () => {
it("shows page progress and wires the Stop button while fetching", () => {
const cancel = vi.fn();
render(
<PaginationStatusAlerts
isFetchingMore={true}
cancelled={false}
progress={{ currentPage: 7, totalPages: 42 }}
cancel={cancel}
/>,
);
expect(screen.getByText(/Currently fetching spend data: fetched 7 \/ 42 pages/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
expect(cancel).toHaveBeenCalledTimes(1);
});
it("shows the partial-data notice after a cancel, frozen at the last fetched page", () => {
render(
<PaginationStatusAlerts
isFetchingMore={false}
cancelled={true}
progress={{ currentPage: 7, totalPages: 42 }}
cancel={vi.fn()}
/>,
);
expect(screen.getByText("Showing partial spend data (7/42 pages loaded)")).toBeInTheDocument();
});
it("calls out a failed page as an error so partial totals do not read as final", () => {
render(
<PaginationStatusAlerts
isFetchingMore={false}
cancelled={false}
failed={true}
progress={{ currentPage: 7, totalPages: 42 }}
cancel={vi.fn()}
/>,
);
expect(
screen.getByText(/Fetching spend data failed, so the totals below cover only 7 of 42 pages of the range/),
).toBeInTheDocument();
});
it("does not claim a page loaded when the very first request is what failed", () => {
render(
<PaginationStatusAlerts
isFetchingMore={false}
cancelled={false}
failed={true}
progress={{ currentPage: 0, totalPages: 0 }}
cancel={vi.fn()}
/>,
);
expect(screen.getByText(/failed before any of it arrived/)).toBeInTheDocument();
expect(screen.queryByText(/pages of the range/)).not.toBeInTheDocument();
});
it("shows only the failure when a stopped fetch also failed", () => {
render(
<PaginationStatusAlerts
isFetchingMore={false}
cancelled={true}
failed={true}
progress={{ currentPage: 7, totalPages: 42 }}
cancel={vi.fn()}
/>,
);
expect(screen.getByText(/Fetching spend data failed/)).toBeInTheDocument();
expect(screen.queryByText(/Showing partial spend data/)).not.toBeInTheDocument();
});
it("names the subject it is fetching", () => {
render(
<PaginationStatusAlerts
isFetchingMore={true}
cancelled={false}
progress={{ currentPage: 1, totalPages: 3 }}
cancel={vi.fn()}
subject="agent data"
/>,
);
expect(screen.getByText(/Currently fetching agent data: fetched 1 \/ 3 pages/)).toBeInTheDocument();
});
it("renders nothing when idle", () => {
const { container } = render(
<PaginationStatusAlerts
isFetchingMore={false}
cancelled={false}
progress={{ currentPage: 1, totalPages: 1 }}
cancel={vi.fn()}
/>,
);
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -1,63 +0,0 @@
import { ExternalLink, Loader2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/shared/Alert";
import { Button } from "@/components/ui/button";
interface PaginationStatusAlertsProps {
isFetchingMore: boolean;
cancelled: boolean;
progress: { currentPage: number; totalPages: number };
cancel: () => void;
subject?: string;
failed?: boolean;
}
const failureMessage = (subject: string, progress: { currentPage: number; totalPages: number }) =>
progress.currentPage === 0
? `Fetching ${subject} failed before any of it arrived, so the totals below are empty rather than final. Reload the page to try again.`
: `Fetching ${subject} failed, so the totals below cover only ${progress.currentPage} of ${progress.totalPages} pages of the range. Reload the page to try again.`;
const PaginationStatusAlerts = ({
isFetchingMore,
cancelled,
progress,
cancel,
subject = "spend data",
failed = false,
}: PaginationStatusAlertsProps) => (
<>
{isFetchingMore && (
<Alert variant="warning" className="mb-2">
<AlertDescription className="flex items-center justify-between text-inherit">
<span>
<Loader2 className="mr-2 inline size-4 animate-spin align-text-bottom" />
Currently fetching {subject}: fetched {progress.currentPage} / {progress.totalPages} pages. Charts will
update periodically as data loads. Moving off of this page will stop and reset this. To continue using the
UI in the meantime,{" "}
<a href={window.location.href} target="_blank" rel="noopener noreferrer">
open a new tab <ExternalLink className="inline size-3.5 align-text-bottom" />
</a>
.
</span>
<Button variant="destructive" onClick={cancel}>
Stop
</Button>
</AlertDescription>
</Alert>
)}
{failed && (
<Alert variant="error" className="mb-2">
<AlertDescription className="text-inherit">{failureMessage(subject, progress)}</AlertDescription>
</Alert>
)}
{cancelled && !failed && (
<Alert variant="info" className="mb-2">
<AlertDescription className="text-inherit">
Showing partial {subject} ({progress.currentPage}/{progress.totalPages} pages loaded)
</AlertDescription>
</Alert>
)}
</>
);
export default PaginationStatusAlerts;

View file

@ -24,19 +24,19 @@ import {
import {
useScopedDailyActivityRange,
type ActivityDateRange,
type DailyActivityScope,
type ScopedActivityInput,
} from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange";
interface ScopedSavingsTabProps {
accessToken: string | null;
scope: DailyActivityScope;
scope: ScopedActivityInput;
activity: ActivityDateRange;
entityType: "key" | "user";
scopeNote?: string;
}
const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote }: ScopedSavingsTabProps) => {
const { dateValue, onDateChange, results, loading, isFetchingMore, failed, cancelled } = useScopedDailyActivityRange(
const { dateValue, onDateChange, results, loading, failed } = useScopedDailyActivityRange(
accessToken,
scope,
activity,
@ -63,8 +63,8 @@ const ScopedSavingsTab = ({ accessToken, scope, activity, entityType, scopeNote
.filter(Boolean)
.join(" · ");
const isLoading = loading || isFetchingMore;
const unavailable = failed || cancelled;
const isLoading = loading;
const unavailable = failed;
const showResults = !isLoading && !unavailable;
const hasRows = results.length > 0;
const showEmpty = !unavailable && (isLoading || !hasRows);

View file

@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import KeySavingsTab from "./KeySavingsTab";
import { DailyData, SpendMetrics } from "@/components/UsagePage/types";
import { EMPTY_DAILY_ACTIVITY_METADATA } from "@/components/UsagePage/dailyActivityApi";
import * as useScopedDailyActivityRangeModule from "@/app/(dashboard)/cost-optimization/_components/useDailyActivityRange";
const metrics = (overrides: Partial<SpendMetrics>): SpendMetrics => ({
@ -36,12 +37,16 @@ const mockActivity = (
dateValue: { from: new Date("2025-01-01"), to: new Date("2025-01-31") },
onDateChange: vi.fn(),
results: [] as DailyData[],
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
loading: false,
isFetchingMore: false,
progress: { currentPage: 1, totalPages: 1 },
cancelled: false,
failed: false,
cancel: vi.fn(),
scope: {
accessToken: "test-token",
startTime: new Date("2025-01-01"),
endTime: new Date("2025-01-31"),
userId: null,
apiKey: null,
},
...overrides,
});

View file

@ -113,6 +113,25 @@ describe("createApiClient", () => {
expect(init.headers).toEqual({ "Content-Type": "application/json" });
});
it("getBlob returns the response body as a Blob on success", async () => {
const blob = new Blob(["csv,data"], { type: "text/csv" });
const fetchImpl = vi.fn(async () => ({ ok: true, status: 200, blob: async () => blob }) as unknown as Response);
const client = createApiClient({ getBaseUrl: () => "https://proxy.example", fetchImpl });
const result = await client.getBlob("/user/daily/activity/export", { accessToken: "sk" });
expect(result).toBe(blob);
const [, blobInit] = fetchImpl.mock.calls[0] as unknown as [unknown, RequestInit];
expect(blobInit.method).toBe("GET");
});
it("getBlob throws ApiError on a non-2xx response", async () => {
const fetchImpl = vi.fn(async () => errorResponse(500, { error: "export failed" }));
const client = createApiClient({ getBaseUrl: () => "", fetchImpl });
await expect(client.getBlob("/user/daily/activity/export", { accessToken: "sk" })).rejects.toBeInstanceOf(ApiError);
});
it("resolves the global fetch per call, so a swap after construction takes effect", async () => {
const client = createApiClient({ getBaseUrl: () => "" });

View file

@ -111,6 +111,7 @@ export interface ApiClientConfig {
export interface ApiClient {
request<T = any>(method: HttpMethod, path: string, options?: RequestOptions): Promise<T>;
get<T = any>(path: string, options?: RequestOptions): Promise<T>;
getBlob(path: string, options?: RequestOptions): Promise<Blob>;
post<T = any>(path: string, options?: RequestOptions): Promise<T>;
put<T = any>(path: string, options?: RequestOptions): Promise<T>;
delete<T = any>(path: string, options?: RequestOptions): Promise<T>;
@ -137,7 +138,7 @@ export function createApiClient(config: ApiClientConfig): ApiClient {
const { getBaseUrl, getAuthHeaderName, onError, fetchImpl } = config;
const doFetch: typeof fetch = (input, init) => (fetchImpl ?? fetch)(input, init);
async function request<T = any>(method: HttpMethod, path: string, options: RequestOptions = {}): Promise<T> {
async function fetchChecked(method: HttpMethod, path: string, options: RequestOptions = {}): Promise<Response> {
const { accessToken, body, rawBody, query, headers: extraHeaders, signal, credentials } = options;
const url = appendQuery(`${getBaseUrl()}${path}`, query);
@ -177,13 +178,24 @@ export function createApiClient(config: ApiClientConfig): ApiClient {
throw new ApiError(message, response.status, errorBody);
}
return response;
}
async function request<T = any>(method: HttpMethod, path: string, options: RequestOptions = {}): Promise<T> {
const response = await fetchChecked(method, path, options);
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
async function getBlob(path: string, options: RequestOptions = {}): Promise<Blob> {
const response = await fetchChecked("GET", path, options);
return response.blob();
}
return {
request,
get: (path, options) => request("GET", path, options),
getBlob,
post: (path, options) => request("POST", path, options),
put: (path, options) => request("PUT", path, options),
delete: (path, options) => request("DELETE", path, options),

File diff suppressed because it is too large Load diff