mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-02 02:11:58 +00:00
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:
commit
5edea75ffd
94 changed files with 14725 additions and 6889 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ services:
|
|||
context: .
|
||||
dockerfile: docker/Dockerfile.non_root
|
||||
target: runtime
|
||||
args:
|
||||
PROXY_EXTRAS_SOURCE: "local"
|
||||
depends_on:
|
||||
- squid
|
||||
user: "101:101"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
589
litellm/proxy/management_endpoints/daily_activity_routes.py
Normal file
589
litellm/proxy/management_endpoints/daily_activity_routes.py
Normal 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)
|
||||
472
litellm/proxy/management_endpoints/daily_activity_scopes.py
Normal file
472
litellm/proxy/management_endpoints/daily_activity_scopes.py
Normal 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",
|
||||
}
|
||||
),
|
||||
)
|
||||
|
|
@ -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}"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1169
tests/integration/spend/golden/daily_activity_team_aggregated.json
Normal file
1169
tests/integration/spend/golden/daily_activity_team_aggregated.json
Normal file
File diff suppressed because it is too large
Load diff
1197
tests/integration/spend/golden/daily_activity_team_paginated.json
Normal file
1197
tests/integration/spend/golden/daily_activity_team_paginated.json
Normal file
File diff suppressed because it is too large
Load diff
1002
tests/integration/spend/golden/daily_activity_user_aggregated.json
Normal file
1002
tests/integration/spend/golden/daily_activity_user_aggregated.json
Normal file
File diff suppressed because it is too large
Load diff
1198
tests/integration/spend/golden/daily_activity_user_paginated.json
Normal file
1198
tests/integration/spend/golden/daily_activity_user_paginated.json
Normal file
File diff suppressed because it is too large
Load diff
514
tests/integration/spend/test_daily_activity_routes.py
Normal file
514
tests/integration/spend/test_daily_activity_routes.py
Normal 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()
|
||||
|
|
@ -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(",")
|
||||
|
|
|
|||
|
|
@ -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(",")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
1355
tests/unit/proxy/management_endpoints/test_daily_activity_routes.py
Normal file
1355
tests/unit/proxy/management_endpoints/test_daily_activity_routes.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" /> }));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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} />);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 "{trimmedQuery}" 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 "{query.trim()}" 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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}),
|
||||
);
|
||||
|
|
@ -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 };
|
||||
});
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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];
|
||||
|
|
@ -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: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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, _]) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: () => "" });
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
2041
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2041
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue