From 339f5b6a4dc7c30e9281861dbe631c69a18a12f6 Mon Sep 17 00:00:00 2001 From: shrey-berri Date: Thu, 1 Oct 2026 16:42:46 -0700 Subject: [PATCH 1/4] fix(bedrock): add beta for mid-conversation tool changes (#43833) --- litellm/llms/anthropic/common_utils.py | 25 ++++++++- .../pass_through/messages/transformation.py | 8 ++- .../anthropic_claude3_transformation.py | 3 ++ litellm/types/llms/anthropic.py | 1 + ...est_anthropic_messages_per_turn_control.py | 30 +++++++++++ .../anthropic/test_anthropic_common_utils.py | 54 +++++++++++++++++++ .../test_anthropic_claude3_transformation.py | 51 ++++++++++++++++++ 7 files changed, 169 insertions(+), 3 deletions(-) diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 30e521b7671..65c2fccceeb 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -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, diff --git a/litellm/llms/anthropic/pass_through/messages/transformation.py b/litellm/llms/anthropic/pass_through/messages/transformation.py index b1be92e49b6..2fbb51ec949 100644 --- a/litellm/llms/anthropic/pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/pass_through/messages/transformation.py @@ -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 diff --git a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py index 73da7c41a09..6234ca3a9c3 100644 --- a/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py @@ -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) diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index 6cd0e55c517..ee357cd6581 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -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" diff --git a/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_per_turn_control.py b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_per_turn_control.py index ef1fac9e120..05c12c6a285 100644 --- a/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_per_turn_control.py +++ b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_per_turn_control.py @@ -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(",") diff --git a/tests/unit/llms/anthropic/test_anthropic_common_utils.py b/tests/unit/llms/anthropic/test_anthropic_common_utils.py index 0904a20a16b..68e2e9650d5 100644 --- a/tests/unit/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/unit/llms/anthropic/test_anthropic_common_utils.py @@ -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(",") diff --git a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py index d7f451dd6ee..a269d556262 100644 --- a/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py +++ b/tests/unit/llms/bedrock/messages/invoke_transformations/test_anthropic_claude3_transformation.py @@ -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) From 54a51c80df0aebd45a40b208498071252aca6d4b Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:57:23 -0700 Subject: [PATCH 2/4] feat(proxy): bounded daily activity routes for all usage entities (#43408) Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- backend/routes/allowlist.py | 1 + litellm/proxy/_lazy_openapi_snapshot.json | 1046 +++++++++ litellm/proxy/_types.py | 52 + litellm/proxy/agent_endpoints/endpoints.py | 175 +- .../common_daily_activity.py | 33 +- .../customer_endpoints.py | 52 +- .../daily_activity_routes.py | 579 +++++ .../daily_activity_scopes.py | 472 ++++ .../internal_user_endpoints.py | 147 +- .../organization_endpoints.py | 89 +- .../tag_management_endpoints.py | 4 +- .../management_endpoints/team_endpoints.py | 102 +- litellm/proxy/proxy_server.py | 2 + .../common_daily_activity.py | 45 + .../endpointaudit/coverage_allowlist.txt | 29 + .../unbounded_in_baseline.txt | 5 +- .../daily_activity_team_aggregated.json | 1169 ++++++++++ .../golden/daily_activity_team_paginated.json | 1197 ++++++++++ .../daily_activity_user_aggregated.json | 1002 ++++++++ .../golden/daily_activity_user_paginated.json | 1198 ++++++++++ .../spend/test_daily_activity_routes.py | 514 +++++ tests/unit/proxy/auth/test_route_checks.py | 89 +- .../test_activity_tenant_scoping.py | 56 + .../test_common_daily_activity.py | 17 +- .../test_daily_activity_routes.py | 1261 ++++++++++ .../test_internal_user_endpoints.py | 182 -- .../test_organization_endpoints.py | 2 +- .../test_team_endpoints.py | 125 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 2041 +++++++++++++++++ 29 files changed, 11031 insertions(+), 655 deletions(-) create mode 100644 litellm/proxy/management_endpoints/daily_activity_routes.py create mode 100644 litellm/proxy/management_endpoints/daily_activity_scopes.py create mode 100644 tests/integration/spend/golden/daily_activity_team_aggregated.json create mode 100644 tests/integration/spend/golden/daily_activity_team_paginated.json create mode 100644 tests/integration/spend/golden/daily_activity_user_aggregated.json create mode 100644 tests/integration/spend/golden/daily_activity_user_paginated.json create mode 100644 tests/integration/spend/test_daily_activity_routes.py create mode 100644 tests/unit/proxy/management_endpoints/test_daily_activity_routes.py diff --git a/backend/routes/allowlist.py b/backend/routes/allowlist.py index 80ca0ef22bb..d7f3e615c67 100644 --- a/backend/routes/allowlist.py +++ b/backend/routes/allowlist.py @@ -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 diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index a9d88c50380..5de6e91a0f2 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -3432,6 +3432,53 @@ "title": "BreakdownMetrics", "type": "object" }, + "DailyActivityKeyPageResponse": { + "properties": { + "api_keys": { + "items": { + "$ref": "#/components/schemas/KeySpendActivityRow" + }, + "title": "Api Keys", + "type": "array" + }, + "limit": { + "title": "Limit", + "type": "integer" + }, + "offset": { + "title": "Offset", + "type": "integer" + }, + "total_api_keys": { + "title": "Total Api Keys", + "type": "integer" + } + }, + "required": [ + "api_keys", + "total_api_keys", + "offset", + "limit" + ], + "title": "DailyActivityKeyPageResponse", + "type": "object" + }, + "DailyActivityKeySearchResponse": { + "properties": { + "api_keys": { + "items": { + "$ref": "#/components/schemas/KeyActivityRow" + }, + "title": "Api Keys", + "type": "array" + } + }, + "required": [ + "api_keys" + ], + "title": "DailyActivityKeySearchResponse", + "type": "object" + }, "DailySpendData": { "properties": { "breakdown": { @@ -3653,6 +3700,16 @@ "title": "EntraIdentityConfig", "type": "object" }, + "ExportType": { + "enum": [ + "daily", + "daily_with_keys", + "daily_with_models", + "daily_with_users" + ], + "title": "ExportType", + "type": "string" + }, "HTTPAuthSecurityScheme": { "description": "Defines a security scheme using HTTP authentication.", "properties": { @@ -3708,6 +3765,27 @@ "title": "HTTPValidationError", "type": "object" }, + "KeyActivityRow": { + "properties": { + "api_key": { + "title": "Api Key", + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/KeyMetadata" + }, + "metrics": { + "$ref": "#/components/schemas/SpendMetrics" + } + }, + "required": [ + "api_key", + "metrics", + "metadata" + ], + "title": "KeyActivityRow", + "type": "object" + }, "KeyMetadata": { "description": "Metadata for a key", "properties": { @@ -3786,6 +3864,78 @@ "title": "KeyMetricWithMetadata", "type": "object" }, + "KeySpendActivityRow": { + "properties": { + "api_key": { + "title": "Api Key", + "type": "string" + }, + "metadata": { + "$ref": "#/components/schemas/KeyMetadata" + }, + "metrics": { + "$ref": "#/components/schemas/KeySpendMetrics" + } + }, + "required": [ + "api_key", + "metrics", + "metadata" + ], + "title": "KeySpendActivityRow", + "type": "object" + }, + "KeySpendMetrics": { + "properties": { + "api_requests": { + "default": 0, + "title": "Api Requests", + "type": "integer" + }, + "cache_creation_input_tokens": { + "default": 0, + "title": "Cache Creation Input Tokens", + "type": "integer" + }, + "cache_read_input_tokens": { + "default": 0, + "title": "Cache Read Input Tokens", + "type": "integer" + }, + "completion_tokens": { + "default": 0, + "title": "Completion Tokens", + "type": "integer" + }, + "failed_requests": { + "default": 0, + "title": "Failed Requests", + "type": "integer" + }, + "prompt_tokens": { + "default": 0, + "title": "Prompt Tokens", + "type": "integer" + }, + "spend": { + "default": 0.0, + "title": "Spend", + "type": "number" + }, + "successful_requests": { + "default": 0, + "title": "Successful Requests", + "type": "integer" + }, + "total_tokens": { + "default": 0, + "title": "Total Tokens", + "type": "integer" + } + }, + "title": "KeySpendMetrics", + "type": "object" + }, "MakeAgentsPublicRequest": { "properties": { "agent_ids": { @@ -3874,6 +4024,32 @@ "title": "MetricWithMetadata", "type": "object" }, + "ModelTopKeysResponse": { + "properties": { + "api_keys": { + "items": { + "$ref": "#/components/schemas/KeySpendActivityRow" + }, + "title": "Api Keys", + "type": "array" + }, + "by_model_group": { + "title": "By Model Group", + "type": "boolean" + }, + "model": { + "title": "Model", + "type": "string" + } + }, + "required": [ + "model", + "by_model_group", + "api_keys" + ], + "title": "ModelTopKeysResponse", + "type": "object" + }, "MutualTLSSecurityScheme": { "description": "Defines a security scheme using mTLS authentication.", "properties": { @@ -4475,6 +4651,876 @@ ] } }, + "/agent/daily/activity/aggregated": { + "get": { + "operationId": "get_agent_daily_activity_aggregated_agent_daily_activity_aggregated_get", + "parameters": [ + { + "in": "query", + "name": "api_key_limit", + "required": false, + "schema": { + "default": 100, + "maximum": 1000, + "minimum": 1, + "title": "Api Key Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Ids" + } + }, + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + { + "in": "query", + "name": "model", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + } + }, + { + "in": "query", + "name": "api_key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + } + }, + { + "in": "query", + "name": "exclude_agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude Agent Ids" + } + }, + { + "in": "query", + "name": "timezone", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timezone" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SpendAnalyticsPaginatedResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Agent Daily Activity Aggregated", + "tags": [ + "agents" + ] + } + }, + "/agent/daily/activity/aggregated/keys": { + "get": { + "operationId": "get_agent_daily_activity_aggregated_keys_agent_daily_activity_aggregated_keys_get", + "parameters": [ + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 50, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Ids" + } + }, + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + { + "in": "query", + "name": "model", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + } + }, + { + "in": "query", + "name": "api_key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + } + }, + { + "in": "query", + "name": "exclude_agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude Agent Ids" + } + }, + { + "in": "query", + "name": "timezone", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timezone" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DailyActivityKeyPageResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Agent Daily Activity Aggregated Keys", + "tags": [ + "agents" + ] + } + }, + "/agent/daily/activity/aggregated/model_top_keys": { + "get": { + "operationId": "get_agent_daily_activity_model_top_keys_agent_daily_activity_aggregated_model_top_keys_get", + "parameters": [ + { + "in": "query", + "name": "model_group", + "required": true, + "schema": { + "minLength": 1, + "title": "Model Group", + "type": "string" + } + }, + { + "in": "query", + "name": "by_model_group", + "required": false, + "schema": { + "default": true, + "title": "By Model Group", + "type": "boolean" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 5, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Ids" + } + }, + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + { + "in": "query", + "name": "model", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + } + }, + { + "in": "query", + "name": "api_key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + } + }, + { + "in": "query", + "name": "exclude_agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude Agent Ids" + } + }, + { + "in": "query", + "name": "timezone", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timezone" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModelTopKeysResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Agent Daily Activity Model Top Keys", + "tags": [ + "agents" + ] + } + }, + "/agent/daily/activity/aggregated/search": { + "get": { + "operationId": "get_agent_daily_activity_aggregated_search_agent_daily_activity_aggregated_search_get", + "parameters": [ + { + "in": "query", + "name": "search", + "required": true, + "schema": { + "minLength": 1, + "title": "Search", + "type": "string" + } + }, + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "default": 100, + "maximum": 100, + "minimum": 1, + "title": "Limit", + "type": "integer" + } + }, + { + "in": "query", + "name": "agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Ids" + } + }, + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + { + "in": "query", + "name": "model", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + } + }, + { + "in": "query", + "name": "api_key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + } + }, + { + "in": "query", + "name": "exclude_agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude Agent Ids" + } + }, + { + "in": "query", + "name": "timezone", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timezone" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DailyActivityKeySearchResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Agent Daily Activity Aggregated Search", + "tags": [ + "agents" + ] + } + }, + "/agent/daily/activity/export": { + "get": { + "operationId": "get_agent_daily_activity_export_agent_daily_activity_export_get", + "parameters": [ + { + "in": "query", + "name": "export_type", + "required": false, + "schema": { + "$ref": "#/components/schemas/ExportType", + "default": "daily" + } + }, + { + "in": "query", + "name": "format", + "required": false, + "schema": { + "default": "csv", + "enum": [ + "csv", + "json" + ], + "title": "Format", + "type": "string" + } + }, + { + "in": "query", + "name": "agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Agent Ids" + } + }, + { + "in": "query", + "name": "start_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Start Date" + } + }, + { + "in": "query", + "name": "end_date", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Date" + } + }, + { + "in": "query", + "name": "model", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Model" + } + }, + { + "in": "query", + "name": "api_key", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key" + } + }, + { + "in": "query", + "name": "exclude_agent_ids", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Exclude Agent Ids" + } + }, + { + "in": "query", + "name": "timezone", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Timezone" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + "text/csv": { + "schema": { + "type": "string" + } + } + }, + "description": "Streamed daily activity export" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ], + "summary": "Get Agent Daily Activity Export", + "tags": [ + "agents" + ] + } + }, "/v1/agents": { "get": { "description": "Example usage:\n```\ncurl -X GET \"http://localhost:4000/v1/agents\" -H \"Content-Type: application/json\" -H \"Authorization: Bearer your-key\" ```\n\nPass `?health_check=true` to filter out agents whose URL is unreachable:\n```\ncurl -X GET \"http://localhost:4000/v1/agents?health_check=true\" -H \"Content-Type: application/json\" -H \"Authorization: Bearer your-key\" ```\n\nPass `?query=` to get the best matching agents ranked by semantic similarity:\n```\ncurl -X GET \"http://localhost:4000/v1/agents?query=translate+a+PDF+document&top_k=5\" -H \"Content-Type: application/json\" -H \"Authorization: Bearer your-key\" ```\n\nReturns: List[AgentResponse]", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index a471fb6f6f8..a745213f9e6 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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}", diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index 875b62103b9..c0f34a24a0a 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -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, diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 59d1d2bf01d..e13d623c73b 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -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({}) diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index b35bc01b4d0..7236fd12e9d 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -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, diff --git a/litellm/proxy/management_endpoints/daily_activity_routes.py b/litellm/proxy/management_endpoints/daily_activity_routes.py new file mode 100644 index 00000000000..065f23c3829 --- /dev/null +++ b/litellm/proxy/management_endpoints/daily_activity_routes.py @@ -0,0 +1,579 @@ +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.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.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 _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)], + 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", + ) + return 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, + ) + 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) diff --git a/litellm/proxy/management_endpoints/daily_activity_scopes.py b/litellm/proxy/management_endpoints/daily_activity_scopes.py new file mode 100644 index 00000000000..4ed3c680f15 --- /dev/null +++ b/litellm/proxy/management_endpoints/daily_activity_scopes.py @@ -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", + } + ), +) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 1fd63c6d456..04b8ec56ae2 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -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}"}, - ) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 24bbd2b4b1f..c8ae7af41db 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -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, diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py index ab33d4bd766..5bf16379d05 100644 --- a/litellm/proxy/management_endpoints/tag_management_endpoints.py +++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py @@ -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, diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 040b27b5802..88d51729c13 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -124,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, @@ -6523,7 +6518,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, @@ -6606,11 +6601,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( @@ -6661,7 +6659,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, @@ -6690,7 +6688,7 @@ async def get_team_daily_activity( _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: @@ -6707,82 +6705,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 ( - 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, - ) - return await get_daily_activity_aggregated( - repository, - activity_scope, - entity_metadata_field=scope.team_alias_metadata, - include_entity_breakdown=True, - ) - - 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 "" @@ -6850,14 +6772,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, diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 9abf949ec1e..9282f216a52 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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) diff --git a/litellm/types/proxy/management_endpoints/common_daily_activity.py b/litellm/types/proxy/management_endpoints/common_daily_activity.py index 5f997945ec2..37804032569 100644 --- a/litellm/types/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/types/proxy/management_endpoints/common_daily_activity.py @@ -124,6 +124,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 diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index 99ed82dcad8..fe89257e83b 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -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 diff --git a/tests/code_coverage_tests/unbounded_in_baseline.txt b/tests/code_coverage_tests/unbounded_in_baseline.txt index 9acc92e2afd..0a95ebfc01f 100644 --- a/tests/code_coverage_tests/unbounded_in_baseline.txt +++ b/tests/code_coverage_tests/unbounded_in_baseline.txt @@ -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 diff --git a/tests/integration/spend/golden/daily_activity_team_aggregated.json b/tests/integration/spend/golden/daily_activity_team_aggregated.json new file mode 100644 index 00000000000..f434e767700 --- /dev/null +++ b/tests/integration/spend/golden/daily_activity_team_aggregated.json @@ -0,0 +1,1169 @@ +{ + "metadata": {"entity_total_api_keys":{"team-1":5}, + "api_key_limit": 100, + "has_more": false, + "page": 1, + "total_api_keys": 5, + "total_api_requests": 5, + "total_autorouter_savings_spend": 0.0, + "total_cache_creation_input_tokens": 0, + "total_cache_read_input_tokens": 6, + "total_completion_tokens": 10, + "total_compression_saved_tokens": 0, + "total_compression_savings_spend": 0.0, + "total_failed_requests": 0, + "total_flat_cost": 0.0, + "total_gateway_injected_caching_savings_spend": 0.0, + "total_pages": 1, + "total_prompt_caching_savings_spend": 0.0, + "total_prompt_tokens": 1014, + "total_response_time_ms": 0, + "total_spend": 1273.0, + "total_successful_requests": 5, + "total_timed_requests": 0, + "total_tokens": 1024 + }, + "results": [ + { + "breakdown": { + "api_keys": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "endpoints": { + "/v1/chat/completions": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "entities": { + "team-1": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": { + "team_alias": "Usage Team" + }, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "mcp_servers": {}, + "model_groups": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "models": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "providers": { + "provider-a": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + } + }, + "date": "2026-06-01", + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + ] +} diff --git a/tests/integration/spend/golden/daily_activity_team_paginated.json b/tests/integration/spend/golden/daily_activity_team_paginated.json new file mode 100644 index 00000000000..e56ebbffe55 --- /dev/null +++ b/tests/integration/spend/golden/daily_activity_team_paginated.json @@ -0,0 +1,1197 @@ +{ + "metadata": {"entity_total_api_keys":null, + "api_key_limit": null, + "has_more": false, + "page": 1, + "total_api_keys": null, + "total_api_requests": 5, + "total_autorouter_savings_spend": 0.0, + "total_cache_creation_input_tokens": 0, + "total_cache_read_input_tokens": 6, + "total_completion_tokens": 10, + "total_compression_saved_tokens": 0, + "total_compression_savings_spend": 0.0, + "total_failed_requests": 0, + "total_flat_cost": 0.0, + "total_gateway_injected_caching_savings_spend": 0.0, + "total_pages": 1, + "total_prompt_caching_savings_spend": 0.0, + "total_prompt_tokens": 1014, + "total_response_time_ms": 0, + "total_spend": 1273.0, + "total_successful_requests": 5, + "total_timed_requests": 0, + "total_tokens": 1024 + }, + "results": [ + { + "breakdown": { + "api_keys": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "endpoints": { + "/v1/chat/completions": { + "api_key_breakdown": { + "__ptu_flat_cost__": { + "metadata": { + "key_alias": null, + "key_exists": false, + "team_id": null, + "user_email": null, + "user_id": null + }, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "entities": { + "team-1": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": { + "team_alias": "Usage Team" + }, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "mcp_servers": {}, + "model_groups": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "models": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "providers": { + "provider-a": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + } + }, + "date": "2026-06-01", + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + ] +} diff --git a/tests/integration/spend/golden/daily_activity_user_aggregated.json b/tests/integration/spend/golden/daily_activity_user_aggregated.json new file mode 100644 index 00000000000..de1bbd63977 --- /dev/null +++ b/tests/integration/spend/golden/daily_activity_user_aggregated.json @@ -0,0 +1,1002 @@ +{ + "metadata": {"entity_total_api_keys":null, + "api_key_limit": 100, + "has_more": false, + "page": 1, + "total_api_keys": 5, + "total_api_requests": 5, + "total_autorouter_savings_spend": 0.0, + "total_cache_creation_input_tokens": 0, + "total_cache_read_input_tokens": 6, + "total_completion_tokens": 10, + "total_compression_saved_tokens": 0, + "total_compression_savings_spend": 0.0, + "total_failed_requests": 0, + "total_flat_cost": 0.0, + "total_gateway_injected_caching_savings_spend": 0.0, + "total_pages": 1, + "total_prompt_caching_savings_spend": 0.0, + "total_prompt_tokens": 1014, + "total_response_time_ms": 0, + "total_spend": 1273.0, + "total_successful_requests": 5, + "total_timed_requests": 0, + "total_tokens": 1024 + }, + "results": [ + { + "breakdown": { + "api_keys": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "endpoints": { + "/v1/chat/completions": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "entities": {}, + "mcp_servers": {}, + "model_groups": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "models": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "providers": { + "provider-a": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + } + }, + "date": "2026-06-01", + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + ] +} diff --git a/tests/integration/spend/golden/daily_activity_user_paginated.json b/tests/integration/spend/golden/daily_activity_user_paginated.json new file mode 100644 index 00000000000..b27bc405ebd --- /dev/null +++ b/tests/integration/spend/golden/daily_activity_user_paginated.json @@ -0,0 +1,1198 @@ +{ + "metadata": {"entity_total_api_keys":null, + "api_key_limit": null, + "has_more": false, + "page": 1, + "total_api_keys": null, + "total_api_requests": 5, + "total_autorouter_savings_spend": 0.0, + "total_cache_creation_input_tokens": 0, + "total_cache_read_input_tokens": 6, + "total_completion_tokens": 10, + "total_compression_saved_tokens": 0, + "total_compression_savings_spend": 0.0, + "total_failed_requests": 0, + "total_flat_cost": 0.0, + "total_gateway_injected_caching_savings_spend": 0.0, + "total_pages": 1, + "total_prompt_caching_savings_spend": 0.0, + "total_prompt_tokens": 1014, + "total_response_time_ms": 0, + "total_spend": 1273.0, + "total_successful_requests": 5, + "total_timed_requests": 0, + "total_tokens": 1024 + }, + "results": [ + { + "breakdown": { + "api_keys": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "endpoints": { + "/v1/chat/completions": { + "api_key_breakdown": { + "__ptu_flat_cost__": { + "metadata": { + "key_alias": null, + "key_exists": false, + "team_id": null, + "user_email": null, + "user_id": null + }, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "entities": { + "user-1": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": { + "user_alias": null, + "user_email": "user@example.com" + }, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + }, + "mcp_servers": {}, + "model_groups": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "models": { + "model-cache": { + "api_key_breakdown": { + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "model-popular": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 3, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 3, + "completion_tokens": 6, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 9, + "spend": 270.0, + "successful_requests": 3, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 15 + } + }, + "model-ptu": { + "api_key_breakdown": {}, + "metadata": {}, + "metrics": { + "api_requests": 0, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "completion_tokens": 0, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 0, + "spend": 1000.0, + "successful_requests": 0, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 0 + } + }, + "model-target": { + "api_key_breakdown": { + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "providers": { + "provider-a": { + "api_key_breakdown": { + "key-a": { + "metadata": { + "key_alias": "alias-a", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 2, + "spend": 100.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 4 + } + }, + "key-b": { + "metadata": { + "key_alias": "alias-b", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 3, + "spend": 90.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 5 + } + }, + "key-c": { + "metadata": { + "key_alias": "alias-c", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 4, + "spend": 80.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 6 + } + }, + "key-cache": { + "metadata": { + "key_alias": "alias-cache", + "key_exists": true, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 1, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1000, + "spend": 2.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1002 + } + }, + "key-target": { + "metadata": { + "key_alias": "deleted-target", + "key_exists": false, + "team_id": "team-1", + "user_email": "user@example.com", + "user_id": "user-1" + }, + "metrics": { + "api_requests": 1, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 2, + "completion_tokens": 2, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 5, + "spend": 1.0, + "successful_requests": 1, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 7 + } + } + }, + "metadata": {}, + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + } + }, + "date": "2026-06-01", + "metrics": { + "api_requests": 5, + "autorouter_savings_spend": 0.0, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 6, + "completion_tokens": 10, + "compression_saved_tokens": 0, + "compression_savings_spend": 0.0, + "failed_requests": 0, + "flat_cost": 0.0, + "gateway_injected_caching_savings_spend": 0.0, + "prompt_caching_savings_spend": 0.0, + "prompt_tokens": 1014, + "spend": 1273.0, + "successful_requests": 5, + "timed_requests": 0, + "total_response_time_ms": 0, + "total_tokens": 1024 + } + } + ] +} diff --git a/tests/integration/spend/test_daily_activity_routes.py b/tests/integration/spend/test_daily_activity_routes.py new file mode 100644 index 00000000000..7c8db96966e --- /dev/null +++ b/tests/integration/spend/test_daily_activity_routes.py @@ -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() diff --git a/tests/unit/proxy/auth/test_route_checks.py b/tests/unit/proxy/auth/test_route_checks.py index d8ee58a52ea..6503e5bf50e 100644 --- a/tests/unit/proxy/auth/test_route_checks.py +++ b/tests/unit/proxy/auth/test_route_checks.py @@ -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( diff --git a/tests/unit/proxy/management_endpoints/test_activity_tenant_scoping.py b/tests/unit/proxy/management_endpoints/test_activity_tenant_scoping.py index 8c80429aa92..bd1436dcd10 100644 --- a/tests/unit/proxy/management_endpoints/test_activity_tenant_scoping.py +++ b/tests/unit/proxy/management_endpoints/test_activity_tenant_scoping.py @@ -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 diff --git a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py index 75e4a865137..45820adb4ce 100644 --- a/tests/unit/proxy/management_endpoints/test_common_daily_activity.py +++ b/tests/unit/proxy/management_endpoints/test_common_daily_activity.py @@ -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"} diff --git a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py new file mode 100644 index 00000000000..076538e5cd0 --- /dev/null +++ b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py @@ -0,0 +1,1261 @@ +import csv +import io +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence +from dataclasses import dataclass, fields +from itertools import chain +from types import SimpleNamespace +from typing import Final +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI, Request +from fastapi.testclient import TestClient + +from litellm import constants +from litellm.proxy._types import LiteLLM_TeamTable, LiteLLM_UserTable, LitellmUserRoles, Member, UserAPIKeyAuth +from litellm.proxy.auth.user_api_key_auth import user_api_key_auth +from litellm.proxy.management_endpoints.daily_activity_routes import ( + _csv_cell, + get_daily_activity_prisma_client, + get_daily_activity_repository, + router, +) +from litellm.types.proxy.management_endpoints.common_daily_activity import KeySpendMetrics, SpendMetrics +from litellm.types.repositories.daily_activity import ( + AggregatedRows, + DailyActivityScope, + DailyActivityTable, + EntityRollupRow, + ExportRow, + ExportType, + GroupingSetsRow, + KeyMetadataRow, + KeyPage, + KeySpendRow, +) + + +@dataclass(frozen=True, slots=True) +class _Activity: + table: DailyActivityTable + entity_id: str + date: str + api_key: str + model: str + model_group: str + spend: float + flat_cost: float + prompt_tokens: int + completion_tokens: int + cache_read_input_tokens: int + cache_creation_input_tokens: int + compression_saved_tokens: int + compression_savings_spend: float + prompt_caching_savings_spend: float + gateway_injected_caching_savings_spend: float + autorouter_savings_spend: float + api_requests: int + successful_requests: int + failed_requests: int + total_response_time_ms: int + timed_requests: int + + +_ENTITY_CASES: Final[tuple[tuple[str, str, str], ...]] = ( + ("/user", "user_id", "user-a"), + ("/team", "team_ids", "team-a"), + ("/tag", "tags", "blue"), + ("/organization", "organization_ids", "org-a"), + ("/customer", "end_user_ids", "customer-a"), + ("/agent", "agent_ids", "agent-a"), +) +_DATE_PARAMS: Final = {"start_date": "2025-01-01", "end_date": "2025-01-02"} + + +def _activity_for_entity( + table: DailyActivityTable, + entity_id: str, + key_rows: tuple[tuple[str, str, str, float, int], ...], +) -> tuple[_Activity, ...]: + return tuple( + _Activity( + table=table, + entity_id=entity_id, + date=date, + api_key=api_key, + model=model, + model_group="rare-group" if model == "rare-model" else "popular-group", + spend=spend, + flat_cost=0.05, + prompt_tokens=10, + completion_tokens=5, + cache_read_input_tokens=cache_read, + cache_creation_input_tokens=1, + compression_saved_tokens=2, + compression_savings_spend=0.1, + prompt_caching_savings_spend=0.2, + gateway_injected_caching_savings_spend=0.3, + autorouter_savings_spend=0.4, + api_requests=1, + successful_requests=1, + failed_requests=0, + total_response_time_ms=100, + timed_requests=1, + ) + for api_key, date, model, spend, cache_read in key_rows + ) + + +def _seeded_activity() -> tuple[_Activity, ...]: + entity_ids: Final = { + DailyActivityTable.USER: "user-a", + DailyActivityTable.TEAM: "team-a", + DailyActivityTable.TAG: "blue", + DailyActivityTable.ORGANIZATION: "org-a", + DailyActivityTable.CUSTOMER: "customer-a", + DailyActivityTable.AGENT: "agent-a", + } + other_entity_ids: Final = { + DailyActivityTable.USER: "user-b", + DailyActivityTable.TEAM: "team-b", + DailyActivityTable.TAG: "other-blue", + DailyActivityTable.ORGANIZATION: "other-org", + DailyActivityTable.CUSTOMER: "customer-b", + DailyActivityTable.AGENT: "agent-b", + } + key_rows: Final = ( + ("key-alpha", "2025-01-01", "popular", 1.0, 0), + ("key-alpha", "2025-01-02", "popular", 2.0, 0), + ("key-beta", "2025-01-01", "popular", 4.0, 0), + ("key-gamma", "2025-01-01", "popular", 5.0, 0), + ("key-cache", "2025-01-01", "popular", 2.0, 20), + ("key-target", "2025-01-01", "rare-model", 0.5, 0), + ) + return tuple( + chain.from_iterable(_activity_for_entity(table, entity_id, key_rows) for table, entity_id in entity_ids.items()) + ) + tuple( + _Activity( + table=table, + entity_id=other_entity_ids[table], + date="2025-01-01", + api_key=f"key-other-{table.value}", + model="popular", + model_group="popular-group", + spend=100.0, + flat_cost=0.0, + prompt_tokens=10, + completion_tokens=5, + cache_read_input_tokens=5 if table is DailyActivityTable.USER else 0, + cache_creation_input_tokens=0, + compression_saved_tokens=0, + compression_savings_spend=0.0, + prompt_caching_savings_spend=0.0, + gateway_injected_caching_savings_spend=0.0, + autorouter_savings_spend=0.0, + api_requests=1, + successful_requests=1, + failed_requests=0, + total_response_time_ms=100, + timed_requests=1, + ) + for table in entity_ids + ) + + +def _metrics(rows: Sequence[_Activity]) -> Mapping[str, int | float]: + return { + "spend": sum(row.spend for row in rows), + "ptu_flat_cost": sum(row.flat_cost for row in rows), + "prompt_tokens": sum(row.prompt_tokens for row in rows), + "completion_tokens": sum(row.completion_tokens for row in rows), + "cache_read_input_tokens": sum(row.cache_read_input_tokens for row in rows), + "cache_creation_input_tokens": sum(row.cache_creation_input_tokens for row in rows), + "compression_saved_tokens": sum(row.compression_saved_tokens for row in rows), + "compression_savings_spend": sum(row.compression_savings_spend for row in rows), + "prompt_caching_savings_spend": sum(row.prompt_caching_savings_spend for row in rows), + "gateway_injected_caching_savings_spend": sum(row.gateway_injected_caching_savings_spend for row in rows), + "autorouter_savings_spend": sum(row.autorouter_savings_spend for row in rows), + "api_requests": sum(row.api_requests for row in rows), + "successful_requests": sum(row.successful_requests for row in rows), + "failed_requests": sum(row.failed_requests for row in rows), + "total_response_time_ms": sum(row.total_response_time_ms for row in rows), + "timed_requests": sum(row.timed_requests for row in rows), + } + + +def _grouping_row( + rows: Sequence[_Activity], + *, + date: str | None, + api_key: str | None, + group_level: int, + distinct_api_keys: int | None, +) -> GroupingSetsRow: + metric_values: Final = _metrics(rows) + return GroupingSetsRow( + date=date, + api_key=api_key, + **metric_values, + model=None, + model_group=None, + custom_llm_provider=None, + mcp_namespaced_tool_name=None, + endpoint=None, + group_level=group_level, + distinct_api_keys=distinct_api_keys, + ) + + +def _grouping_rows_for_day( + date: str, rows: Sequence[_Activity], top_keys: tuple[str, ...], distinct_api_keys: int +) -> tuple[GroupingSetsRow, ...]: + date_rows: Final = tuple(row for row in rows if row.date == date) + return ( + _grouping_row(date_rows, date=date, api_key=None, group_level=63, distinct_api_keys=distinct_api_keys), + ) + tuple( + _grouping_row( + tuple(row for row in date_rows if row.api_key == api_key), + date=date, + api_key=api_key, + group_level=31, + distinct_api_keys=None, + ) + for api_key in top_keys + if any(row.api_key == api_key for row in date_rows) + ) + + +class _FakeRepository: + def __init__(self, rows: tuple[_Activity, ...]) -> None: + self._rows: Final = rows + self.aggregated = AsyncMock(side_effect=self._aggregated) + self.key_page = AsyncMock(side_effect=self._key_page) + self.key_page_call: tuple[DailyActivityScope, int, int] | None = None + self.search_keys = AsyncMock(side_effect=self._search_keys) + self.model_top_keys = AsyncMock(side_effect=self._model_top_keys) + self.cache_leakage_keys = AsyncMock(side_effect=self._cache_leakage_keys) + self.key_metadata = AsyncMock(side_effect=self._key_metadata) + self.export_rows_error: Exception | None = None + + def _matching_rows(self, scope: DailyActivityScope) -> tuple[_Activity, ...]: + return tuple( + row + for row in self._rows + if row.table == scope.table + and scope.start_date <= row.date <= scope.end_date + and (scope.entity_ids is None or row.entity_id in scope.entity_ids) + and row.entity_id not in scope.exclude_entity_ids + and (scope.api_keys is None or row.api_key in scope.api_keys) + and (scope.model is None or row.model == scope.model) + ) + + async def _aggregated( + self, + scope: DailyActivityScope, + *, + include_entity_breakdown: bool = False, + api_key_limit: int = constants.USAGE_TOP_API_KEYS_DEFAULT, + ) -> AggregatedRows: + rows: Final = self._matching_rows(scope) + key_spend: Final = tuple( + sorted( + ((key, sum(row.spend for row in rows if row.api_key == key)) for key in {row.api_key for row in rows}), + key=lambda item: (-item[1], item[0]), + ) + ) + distinct_keys: Final = len(key_spend) + top_keys: Final = tuple(key for key, _ in key_spend[:api_key_limit]) + dates: Final = tuple(sorted({row.date for row in rows})) + grouping_rows: Final = ( + _grouping_row(rows, date=None, api_key=None, group_level=127, distinct_api_keys=distinct_keys), + ) + tuple( + grouping_row + for date in dates + for grouping_row in _grouping_rows_for_day(date, rows, top_keys, distinct_keys) + ) + entity_rows: Final = ( + tuple(entity_row for date in dates for entity_row in _entity_rows_for_day(date, rows)) + if include_entity_breakdown + else () + ) + return AggregatedRows( + grouping_rows=grouping_rows, + entity_rows=entity_rows if include_entity_breakdown else None, + distinct_api_keys=distinct_keys, + ) + + async def _search_keys(self, scope: DailyActivityScope, *, search: str, limit: int) -> tuple[str, ...]: + rows: Final = self._matching_rows(scope) + return tuple(key for key in dict.fromkeys(row.api_key for row in rows) if search.casefold() in key.casefold())[ + :limit + ] + + async def _key_page(self, scope: DailyActivityScope, *, offset: int, limit: int) -> KeyPage: + self.key_page_call = (scope, offset, limit) + rows: Final = self._matching_rows(scope) + api_keys: Final = _ranked_keys(rows) + return KeyPage( + rows=tuple(_key_spend_row(api_key, rows) for api_key in api_keys[offset : offset + limit]), + total_api_keys=len(api_keys), + ) + + async def _model_top_keys( + self, scope: DailyActivityScope, *, model_group: str, by_model_group: bool, limit: int + ) -> tuple[KeySpendRow, ...]: + rows: Final = tuple( + row + for row in self._matching_rows(scope) + if (row.model_group if by_model_group else row.model) == model_group + ) + return tuple(_key_spend_row(key, rows) for key in _ranked_keys(rows)[:limit]) + + async def _cache_leakage_keys(self, scope: DailyActivityScope, *, limit: int) -> tuple[KeySpendRow, ...]: + rows: Final = tuple(row for row in self._matching_rows(scope) if row.cache_read_input_tokens > 0) + return tuple(_key_spend_row(key, rows) for key in _ranked_keys(rows)[:limit]) + + async def _key_metadata( + self, api_keys: frozenset[str], window: tuple[object, object] | None + ) -> Mapping[str, KeyMetadataRow]: + return { + key: KeyMetadataRow( + api_key=key, + key_alias=f"alias-{key}", + team_id="team-a", + user_id="user-a", + user_email="user@example.test", + key_exists=True, + tags=(), + ) + for key in api_keys + } + + async def export_rows(self, scope: DailyActivityScope, *, export_type: ExportType) -> AsyncIterator[ExportRow]: + if self.export_rows_error is not None: + raise self.export_rows_error + for row in self._matching_rows(scope): + yield ExportRow( + date=row.date, + entity_id=row.entity_id, + entity_alias="=entity", + api_key=row.api_key, + key_alias="+key", + user_id="user-a", + user_email="user@example.test", + model=row.model, + spend=row.spend, + flat_cost=row.flat_cost, + prompt_tokens=row.prompt_tokens, + completion_tokens=row.completion_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, + ) + + +def _ranked_keys(rows: Sequence[_Activity]) -> tuple[str, ...]: + return tuple( + key + for key, _ in sorted( + ((key, sum(row.spend for row in rows if row.api_key == key)) for key in {row.api_key for row in rows}), + key=lambda item: (-item[1], item[0]), + ) + ) + + +def _key_spend_row(api_key: str, rows: Sequence[_Activity]) -> KeySpendRow: + matching: Final = tuple(row for row in rows if row.api_key == api_key) + return KeySpendRow( + api_key=api_key, + spend=sum(row.spend for row in matching), + prompt_tokens=sum(row.prompt_tokens for row in matching), + completion_tokens=sum(row.completion_tokens for row in matching), + total_tokens=sum(row.prompt_tokens + row.completion_tokens for row in matching), + api_requests=sum(row.api_requests for row in matching), + successful_requests=sum(row.successful_requests for row in matching), + failed_requests=sum(row.failed_requests for row in matching), + cache_read_input_tokens=sum(row.cache_read_input_tokens for row in matching), + cache_creation_input_tokens=sum(row.cache_creation_input_tokens for row in matching), + ) + + +def _entity_rows_for_day( + date: str, rows: Sequence[_Activity], distinct_api_keys: int | None = None +) -> tuple[EntityRollupRow, ...]: + date_rows: Final = tuple(row for row in rows if row.date == date) + entities: Final = tuple(dict.fromkeys(row.entity_id for row in date_rows)) + return tuple( + entity_row + for entity_id in entities + for entity_row in _entity_rows_for_entity(date, entity_id, date_rows, distinct_api_keys) + ) + + +def _entity_rows_for_entity( + date: str, entity_id: str, rows: Sequence[_Activity], distinct_api_keys: int | None +) -> tuple[EntityRollupRow, ...]: + entity_rows: Final = tuple(row for row in rows if row.entity_id == entity_id) + return ( + _entity_rollup( + entity_rows, + date=date, + entity_id=entity_id, + api_key=None, + api_key_rolled=1, + distinct_api_keys=distinct_api_keys, + ), + ) + tuple( + _entity_rollup( + tuple(row for row in entity_rows if row.api_key == api_key), + date=date, + entity_id=entity_id, + api_key=api_key, + api_key_rolled=0, + distinct_api_keys=None, + ) + for api_key in dict.fromkeys(row.api_key for row in entity_rows) + ) + + +def _entity_rollup( + rows: Sequence[_Activity], + *, + date: str, + entity_id: str, + api_key: str | None, + api_key_rolled: int, + distinct_api_keys: int | None, +) -> EntityRollupRow: + return EntityRollupRow( + date=date, + api_key=api_key, + **_metrics(rows), + entity_id=entity_id, + api_key_rolled=api_key_rolled, + distinct_api_keys=distinct_api_keys, + ) + + +class _PrismaTable: + def __init__(self, rows: tuple[object, ...] = ()) -> None: + self._rows: Final = rows + + async def find_many(self, *, where: Mapping[str, object] | None = None, **kwargs: object) -> tuple[object, ...]: + if where is None: + return self._rows + return tuple(row for row in self._rows if _matches(row, where)) + + async def find_unique(self, *, where: Mapping[str, object], **kwargs: object) -> object | None: + return next((row for row in self._rows if _matches(row, where)), None) + + +def _matches(row: object, where: Mapping[str, object]) -> bool: + return all( + getattr(row, field_name, None) in value["in"] + if isinstance(value, Mapping) and "in" in value + else getattr(row, field_name, None) == value + for field_name, value in where.items() + ) + + +def _prisma_client() -> object: + user: Final = LiteLLM_UserTable( + user_id="user-a", + user_email="user@example.test", + user_role=LitellmUserRoles.INTERNAL_USER.value, + teams=["team-a"], + ) + team: Final = LiteLLM_TeamTable( + team_id="team-a", + team_alias="Team A", + members_with_roles=[Member(user_id="user-a", role="user")], + ) + other_user: Final = LiteLLM_UserTable( + user_id="user-b", + user_email="other-user@example.test", + user_role=LitellmUserRoles.INTERNAL_USER.value, + teams=["team-b"], + ) + other_team: Final = LiteLLM_TeamTable( + team_id="team-b", + team_alias="Team B", + members_with_roles=[Member(user_id="user-b", role="user")], + ) + db: Final = SimpleNamespace( + litellm_usertable=_PrismaTable((user, other_user)), + litellm_teamtable=_PrismaTable((team, other_team)), + litellm_verificationtoken=_PrismaTable((SimpleNamespace(token="key-alpha", user_id="user-a"),)), + litellm_organizationmembership=_PrismaTable( + ( + SimpleNamespace(user_id="user-a", organization_id="org-a", user_role="org_admin"), + SimpleNamespace(user_id="user-b", organization_id="other-org", user_role="org_admin"), + ) + ), + litellm_organizationtable=_PrismaTable( + ( + SimpleNamespace(organization_id="org-a", organization_alias="Org A"), + SimpleNamespace(organization_id="other-org", organization_alias="Other Org"), + ) + ), + litellm_endusertable=_PrismaTable( + ( + SimpleNamespace(user_id="customer-a", alias="Customer A"), + SimpleNamespace(user_id="customer-b", alias="Customer B"), + ) + ), + litellm_agentstable=_PrismaTable( + ( + SimpleNamespace(agent_id="agent-a", agent_name="Agent A", created_by="user-a"), + SimpleNamespace(agent_id="agent-b", agent_name="Agent B", created_by="user-b"), + ) + ), + ) + return SimpleNamespace(db=db, writer_db=db) + + +@pytest.fixture +def daily_activity_client() -> Iterator[tuple[TestClient, _FakeRepository]]: + repository: Final = _FakeRepository(_seeded_activity()) + prisma_client: Final = _prisma_client() + app: Final = FastAPI() + app.include_router(router) + + def resolve_auth(request: Request) -> UserAPIKeyAuth: + role: Final = LitellmUserRoles(request.headers.get("x-user-role", LitellmUserRoles.PROXY_ADMIN.value)) + user_id: Final[str | None] = request.headers.get("x-user-id") or ( + "admin" if role != LitellmUserRoles.INTERNAL_USER else None + ) + return UserAPIKeyAuth( + user_id=user_id, + user_role=role, + api_key=request.headers.get("x-api-key"), + ) + + app.dependency_overrides[get_daily_activity_repository] = lambda: repository + app.dependency_overrides[get_daily_activity_prisma_client] = lambda: prisma_client + app.dependency_overrides[user_api_key_auth] = resolve_auth + with TestClient(app) as client: + yield client, repository + + +def _entity_params(query_name: str, entity_id: str) -> dict[str, str]: + return {**_DATE_PARAMS, query_name: entity_id} + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_aggregated_routes_return_scoped_results( + daily_activity_client: tuple[TestClient, _FakeRepository], + prefix: str, + query_name: str, + entity_id: str, +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/aggregated", + params=_entity_params(query_name, entity_id), + ) + assert response.status_code == 200, response.text + body: Final = response.json() + assert body["metadata"]["total_spend"] == pytest.approx(14.5), response.text + assert body["metadata"]["total_api_keys"] == 5, response.text + assert len(body["results"]) == 2, response.text + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_admin_aggregates_all_entities_when_filter_is_omitted( + daily_activity_client: tuple[TestClient, _FakeRepository], prefix: str, query_name: str, entity_id: str +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/aggregated", + params=_DATE_PARAMS, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == pytest.approx(114.5), response.text + assert response.json()["metadata"]["total_api_keys"] == 6, response.text + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_search_folds_each_entity_key_across_days( + daily_activity_client: tuple[TestClient, _FakeRepository], prefix: str, query_name: str, entity_id: str +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/aggregated/search", + params={**_entity_params(query_name, entity_id), "search": "alpha"}, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "api_keys": [ + { + "api_key": "key-alpha", + "metrics": { + "spend": 3.0, + "flat_cost": 0.0, + "prompt_tokens": 20, + "completion_tokens": 10, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 2, + "compression_saved_tokens": 4, + "compression_savings_spend": 0.2, + "prompt_caching_savings_spend": 0.4, + "gateway_injected_caching_savings_spend": 0.6, + "autorouter_savings_spend": 0.8, + "total_tokens": 30, + "successful_requests": 2, + "failed_requests": 0, + "api_requests": 2, + "total_response_time_ms": 200, + "timed_requests": 2, + }, + "metadata": { + "key_alias": "alias-key-alpha", + "team_id": "team-a", + "user_id": "user-a", + "user_email": "user@example.test", + "key_exists": True, + }, + } + ] + } + + +def test_search_finds_keys_outside_the_top_keys_limit_and_skips_empty_aggregate( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + aggregate_response: Final = client.get( + "/user/daily/activity/aggregated", + params={**_entity_params("user_id", "user-a"), "api_key_limit": 3}, + ) + assert aggregate_response.status_code == 200, aggregate_response.text + assert repository.aggregated.call_args.kwargs["api_key_limit"] == 3 + top_keys: Final = frozenset( + chain.from_iterable(result["breakdown"]["api_keys"] for result in aggregate_response.json()["results"]) + ) + assert "key-target" not in top_keys + + search_response: Final = client.get( + "/user/daily/activity/aggregated/search", + params={**_entity_params("user_id", "user-a"), "search": "target", "limit": 7}, + ) + assert search_response.status_code == 200, search_response.text + assert repository.search_keys.call_args.kwargs["limit"] == 7 + assert search_response.json()["api_keys"][0]["api_key"] == "key-target" + assert search_response.json()["api_keys"][0]["metrics"]["spend"] == pytest.approx(0.5) + search_metrics: Final = search_response.json()["api_keys"][0]["metrics"] + assert set(search_metrics) == set(SpendMetrics.model_fields) + assert search_metrics["compression_savings_spend"] == pytest.approx(0.1) + assert search_metrics["total_response_time_ms"] == 100 + + repository.aggregated.reset_mock() + empty_response: Final = client.get( + "/user/daily/activity/aggregated/search", + params={**_entity_params("user_id", "user-a"), "search": "absent"}, + ) + assert empty_response.status_code == 200, empty_response.text + assert empty_response.json() == {"api_keys": []} + repository.aggregated.assert_not_awaited() + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_key_page_routes_map_ranked_rows_and_totals( + daily_activity_client: tuple[TestClient, _FakeRepository], + prefix: str, + query_name: str, + entity_id: str, +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/aggregated/keys", + params={**_entity_params(query_name, entity_id), "offset": 1, "limit": 2}, + ) + + assert response.status_code == 200, response.text + assert response.json() == { + "api_keys": [ + { + "api_key": "key-beta", + "metrics": { + "spend": 4.0, + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + "api_requests": 1, + "successful_requests": 1, + "failed_requests": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 1, + }, + "metadata": { + "key_alias": "alias-key-beta", + "team_id": "team-a", + "user_id": "user-a", + "user_email": "user@example.test", + "key_exists": True, + }, + }, + { + "api_key": "key-alpha", + "metrics": { + "spend": 3.0, + "prompt_tokens": 20, + "completion_tokens": 10, + "total_tokens": 30, + "api_requests": 2, + "successful_requests": 2, + "failed_requests": 0, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 2, + }, + "metadata": { + "key_alias": "alias-key-alpha", + "team_id": "team-a", + "user_id": "user-a", + "user_email": "user@example.test", + "key_exists": True, + }, + }, + ], + "total_api_keys": 5, + "offset": 1, + "limit": 2, + } + key_page_call: Final = repository.key_page_call + assert key_page_call is not None + scope: Final = key_page_call[0] + assert scope.entity_ids == (entity_id,) + assert key_page_call[1:] == (1, 2) + + +@pytest.mark.parametrize("params", ({"limit": 101}, {"offset": -1})) +def test_key_page_route_rejects_invalid_bounds( + daily_activity_client: tuple[TestClient, _FakeRepository], + params: Mapping[str, int], +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/user/daily/activity/aggregated/keys", + params={**_entity_params("user_id", "user-a"), **params}, + ) + + assert response.status_code == 422, response.text + repository.key_page.assert_not_awaited() + + +@pytest.mark.parametrize( + ("path", "route_params", "limit_name", "invalid_limit"), + ( + ("/user/daily/activity/aggregated", {}, "api_key_limit", 0), + ( + "/user/daily/activity/aggregated", + {}, + "api_key_limit", + constants.USAGE_TOP_API_KEYS_MAX + 1, + ), + ("/user/daily/activity/aggregated/search", {"search": "key"}, "limit", 0), + ( + "/user/daily/activity/aggregated/search", + {"search": "key"}, + "limit", + constants.USAGE_KEY_SEARCH_MAX + 1, + ), + ( + "/user/daily/activity/aggregated/model_top_keys", + {"model_group": "popular-group"}, + "limit", + 0, + ), + ( + "/user/daily/activity/aggregated/model_top_keys", + {"model_group": "popular-group"}, + "limit", + constants.USAGE_MODEL_TOP_KEYS_MAX + 1, + ), + ( + "/user/daily/activity/aggregated/cache_leakage_keys", + {}, + "limit", + 0, + ), + ( + "/user/daily/activity/aggregated/cache_leakage_keys", + {}, + "limit", + constants.USAGE_CACHE_LEAKAGE_KEYS_MAX + 1, + ), + ), +) +def test_usage_limit_routes_reject_values_outside_bounds( + daily_activity_client: tuple[TestClient, _FakeRepository], + path: str, + route_params: Mapping[str, str], + limit_name: str, + invalid_limit: int, +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + path, + params={ + **_entity_params("user_id", "user-a"), + **route_params, + limit_name: invalid_limit, + }, + ) + + assert response.status_code == 422, response.text + repository.aggregated.assert_not_awaited() + repository.search_keys.assert_not_awaited() + repository.model_top_keys.assert_not_awaited() + repository.cache_leakage_keys.assert_not_awaited() + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_model_top_routes_rank_keys_and_include_metadata( + daily_activity_client: tuple[TestClient, _FakeRepository], prefix: str, query_name: str, entity_id: str +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/aggregated/model_top_keys", + params={ + **_entity_params(query_name, entity_id), + "model_group": "rare-group", + "limit": 3, + }, + ) + assert response.status_code == 200, response.text + assert repository.model_top_keys.call_args.kwargs["limit"] == 3 + assert response.json()["model"] == "rare-group" + assert response.json()["by_model_group"] is True + assert tuple(row["api_key"] for row in response.json()["api_keys"]) == ("key-target",) + metrics: Final = response.json()["api_keys"][0]["metrics"] + expected_row: Final = KeySpendRow( + api_key="key-target", + spend=0.5, + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + api_requests=1, + successful_requests=1, + failed_requests=0, + cache_read_input_tokens=0, + cache_creation_input_tokens=1, + ) + assert set(metrics) == set(KeySpendMetrics.model_fields) + assert metrics == {field: getattr(expected_row, field) for field in KeySpendMetrics.model_fields} + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_export_routes_stream_csv_and_preserve_row_counts( + daily_activity_client: tuple[TestClient, _FakeRepository], + prefix: str, + query_name: str, + entity_id: str, +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/export", + params={**_entity_params(query_name, entity_id), "export_type": ExportType.DAILY.value}, + ) + assert response.status_code == 200, response.text + assert response.headers["cache-control"] == "no-store" + assert "attachment;" in response.headers["content-disposition"] + records: Final = tuple(csv.reader(io.StringIO(response.text))) + assert tuple(records[0]) == tuple(field.name for field in fields(ExportRow)) + assert len(records) == 7 + assert records[1][2] == "'=entity" + assert records[1][4] == "'+key" + + +@pytest.mark.parametrize(("prefix", "query_name", "entity_id"), _ENTITY_CASES) +def test_export_routes_stream_json_arrays( + daily_activity_client: tuple[TestClient, _FakeRepository], + prefix: str, + query_name: str, + entity_id: str, +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/export", + params={**_entity_params(query_name, entity_id), "format": "json"}, + ) + assert response.status_code == 200, response.text + assert response.headers["content-type"].startswith("application/json") + assert response.headers["cache-control"] == "no-store" + records: Final = response.json() + assert isinstance(records, list) and len(records) == 6, response.text + assert records[0]["entity_alias"] == "=entity" + + +def test_export_first_row_error_returns_json_error_before_streaming( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + repository.export_rows_error = RuntimeError("database query failed") + response: Final = client.get( + "/team/daily/activity/export", + params={**_entity_params("team_ids", "team-a"), "format": "csv"}, + ) + assert response.status_code >= 400 + assert response.headers["content-type"].startswith("application/json") + assert response.text != ",".join(field.name for field in fields(ExportRow)) + "\r\n" + assert "database query failed" in response.text + + +def test_csv_export_with_no_rows_contains_only_header( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + "/team/daily/activity/export", + params={**_entity_params("team_ids", "team-a"), "api_key": "missing-key"}, + ) + assert response.status_code == 200, response.text + assert response.text == ",".join(field.name for field in fields(ExportRow)) + "\r\n" + + +def test_json_export_with_no_rows_is_an_empty_array( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + "/team/daily/activity/export", + params={**_entity_params("team_ids", "team-a"), "api_key": "missing-key", "format": "json"}, + ) + assert response.status_code == 200, response.text + assert response.json() == [] + + +def test_user_routes_preserve_scope_denials_and_service_account_guard( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + denied: Final = client.get( + "/user/daily/activity/aggregated", + params=_entity_params("user_id", "user-b"), + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"}, + ) + assert denied.status_code == 403, denied.text + repository.aggregated.assert_not_awaited() + + service_account: Final = client.get( + "/user/daily/activity/aggregated", + params=_DATE_PARAMS, + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value}, + ) + assert service_account.status_code == 403, service_account.text + repository.aggregated.assert_not_awaited() + + own_scope: Final = client.get( + "/user/daily/activity/aggregated", + params=_DATE_PARAMS, + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"}, + ) + assert own_scope.status_code == 200, own_scope.text + assert own_scope.json()["metadata"]["total_spend"] == pytest.approx(14.5) + assert own_scope.json()["metadata"]["total_api_keys"] == 5 + + +def test_team_scope_applies_membership_and_user_key_filter( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/team/daily/activity/aggregated", + params={**_entity_params("team_ids", "team-a"), "timezone": "480"}, + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"}, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == pytest.approx(3.0) + assert response.json()["metadata"]["total_api_keys"] == 1 + scope: Final = repository.aggregated.call_args.args[0] + assert scope.api_keys == ("key-alpha",) + assert scope.entity_ids == ("team-a",) + assert scope.timezone_offset_minutes == 480 + assert repository.aggregated.call_args.kwargs["include_entity_breakdown"] is True + + +def test_team_scope_does_not_allow_an_unowned_api_key( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + "/team/daily/activity/aggregated", + params={**_entity_params("team_ids", "team-a"), "api_key": "key-beta"}, + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"}, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == 0 + assert response.json()["metadata"]["total_api_keys"] == 0 + assert "key-beta" not in response.text + + +def _assert_customer_route_denied(client: TestClient, prefix: str, suffix: str, extra_params: dict[str, str]) -> None: + response: Final = client.get( + f"{prefix}/daily/activity/{suffix}", + params={**_entity_params("end_user_ids", "customer-a"), **extra_params}, + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"}, + ) + assert response.status_code == 403, response.text + + +def test_customer_service_routes_deny_non_admins(daily_activity_client: tuple[TestClient, _FakeRepository]) -> None: + client, repository = daily_activity_client + route_params: Final = ( + ("aggregated", {}), + ("aggregated/search", {"search": "alpha"}), + ("aggregated/model_top_keys", {"model_group": "rare-group"}), + ("export", {"export_type": ExportType.DAILY.value}), + ) + for prefix in ("/customer", "/end_user"): + for suffix, extra_params in route_params: + _assert_customer_route_denied(client, prefix, suffix, extra_params) + repository.aggregated.assert_not_awaited() + + +def test_customer_end_user_aliases_are_hidden_from_openapi( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, _ = daily_activity_client + paths: Final = client.get("/openapi.json").json()["paths"] + assert "/customer/daily/activity/aggregated" in paths + assert "/end_user/daily/activity/aggregated" not in paths + response: Final = client.get( + "/end_user/daily/activity/aggregated", + params=_entity_params("end_user_ids", "customer-a"), + ) + assert response.status_code == 200, response.text + + +@pytest.mark.parametrize( + ("prefix", "query_name", "entity_id"), + _ENTITY_CASES[:4] + (_ENTITY_CASES[-1],), +) +@pytest.mark.parametrize( + ("family", "extra_params"), + ( + ("aggregated", {}), + ("aggregated/search", {"search": "key"}), + ("aggregated/model_top_keys", {"model_group": "popular-group"}), + ("export", {"export_type": ExportType.DAILY.value}), + ), +) +def test_non_admin_routes_return_only_permitted_entities_and_keys( + daily_activity_client: tuple[TestClient, _FakeRepository], + prefix: str, + query_name: str, + entity_id: str, + family: str, + extra_params: Mapping[str, str], +) -> None: + client, _ = daily_activity_client + headers: Final = {"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"} + response: Final = client.get( + f"{prefix}/daily/activity/{family}", + params={**_entity_params(query_name, entity_id), **extra_params}, + headers=headers, + ) + assert response.status_code == 200, response.text + assert "key-other-" not in response.text, response.text + if prefix in ("/team", "/tag"): + assert "key-beta" not in response.text, response.text + assert "key-alpha" in response.text, response.text + + +def test_empty_scope_filters_fail_closed(daily_activity_client: tuple[TestClient, _FakeRepository]) -> None: + client, _ = daily_activity_client + response: Final = client.get( + "/tag/daily/activity/aggregated", + params=_entity_params("tags", "blue"), + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-empty"}, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == 0 + assert response.json()["results"] == [] + + +@pytest.mark.parametrize( + ("prefix", "query_name", "entity_id", "other_entity_id", "exclude_query_name"), + ( + ("/team", "team_ids", "team-a", "team-b", "exclude_team_ids"), + ("/organization", "organization_ids", "org-a", "other-org", "exclude_organization_ids"), + ("/customer", "end_user_ids", "customer-a", "customer-b", "exclude_end_user_ids"), + ("/agent", "agent_ids", "agent-a", "agent-b", "exclude_agent_ids"), + ), +) +def test_exclusion_filters_apply_after_entity_scope( + daily_activity_client: tuple[TestClient, _FakeRepository], + prefix: str, + query_name: str, + entity_id: str, + other_entity_id: str, + exclude_query_name: str, +) -> None: + client, _ = daily_activity_client + response: Final = client.get( + f"{prefix}/daily/activity/aggregated", + params={ + **_DATE_PARAMS, + query_name: f"{entity_id},{other_entity_id}", + exclude_query_name: entity_id, + }, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == pytest.approx(100) + assert response.json()["metadata"]["total_api_keys"] == 1 + + +def test_csv_formula_escaping_covers_all_supported_leading_characters() -> None: + dangerous_values: Final = ("=sum", "+sum", "-sum", "@sum", "\tsum", "\rsum") + assert tuple(_csv_cell(value) for value in dangerous_values) == tuple(f"'{value}" for value in dangerous_values) + + +def test_user_cache_leakage_route_returns_cache_keys_and_metadata( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/user/daily/activity/aggregated/cache_leakage_keys", + params={**_entity_params("user_id", "user-a"), "limit": 4}, + ) + assert response.status_code == 200, response.text + assert repository.cache_leakage_keys.call_args.kwargs["limit"] == 4 + assert tuple(row["api_key"] for row in response.json()["api_keys"]) == ("key-cache",) + metrics: Final = response.json()["api_keys"][0]["metrics"] + expected_row: Final = KeySpendRow( + api_key="key-cache", + spend=2.0, + prompt_tokens=10, + completion_tokens=5, + total_tokens=15, + api_requests=1, + successful_requests=1, + failed_requests=0, + cache_read_input_tokens=20, + cache_creation_input_tokens=1, + ) + assert set(metrics) == set(KeySpendMetrics.model_fields) + assert metrics == {field: getattr(expected_row, field) for field in KeySpendMetrics.model_fields} + assert response.json()["api_keys"][0]["metadata"]["key_alias"] == "alias-key-cache" + repository.cache_leakage_keys.assert_awaited_once() + repository.key_metadata.assert_awaited_once() + assert repository.key_metadata.call_args.args[0] == frozenset(("key-cache",)) + assert repository.key_metadata.call_args.args[1] is not None + + +def test_user_cache_leakage_route_respects_requested_user_scope( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/user/daily/activity/aggregated/cache_leakage_keys", + params=_entity_params("user_id", "user-missing"), + ) + assert response.status_code == 200, response.text + assert response.json() == {"api_keys": []} + scope: Final = repository.cache_leakage_keys.call_args.args[0] + assert scope.entity_ids == ("user-missing",) + + +def test_export_json_stream_has_all_seeded_rows(daily_activity_client: tuple[TestClient, _FakeRepository]) -> None: + client, _ = daily_activity_client + response: Final = client.get( + "/user/daily/activity/export", + params={ + **_entity_params("user_id", "user-a"), + "export_type": ExportType.DAILY.value, + "format": "json", + }, + ) + assert response.status_code == 200, response.text + assert response.headers["content-type"].startswith("application/json") + assert len(response.json()) == 6 + + +def test_user_internal_role_is_scoped_to_api_key(daily_activity_client: tuple[TestClient, _FakeRepository]) -> None: + client, _ = daily_activity_client + response: Final = client.get( + "/tag/daily/activity/aggregated", + params=_entity_params("tags", "blue"), + headers={ + "x-user-role": LitellmUserRoles.INTERNAL_USER.value, + "x-user-id": "user-a", + }, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == pytest.approx(3.0) + + +def test_user_aggregate_keeps_current_day_query_semantics( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/user/daily/activity/aggregated", + params={**_DATE_PARAMS, "user_id": "user-a", "timezone": 480, "include_current_utc_day": "true"}, + ) + assert response.status_code == 200, response.text + assert response.json()["metadata"]["total_spend"] == pytest.approx(14.5) + scope: Final = repository.aggregated.call_args.args[0] + assert scope.entity_ids == ("user-a",) + assert scope.timezone_offset_minutes == 480 + assert scope.include_current_utc_day is True + + +@pytest.mark.parametrize( + ("start_date", "end_date", "message"), + ( + ("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"), + ), +) +def test_team_aggregated_route_rejects_bad_date_ranges( + daily_activity_client: tuple[TestClient, _FakeRepository], + start_date: str | None, + end_date: str | None, + message: str, +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/team/daily/activity/aggregated", + params={"start_date": start_date, "end_date": end_date, "team_ids": "team-a"}, + ) + assert response.status_code == 400, response.text + assert message in str(response.json()["detail"]), response.text + repository.aggregated.assert_not_awaited() + + +def test_user_aggregate_rejects_missing_dates(daily_activity_client: tuple[TestClient, _FakeRepository]) -> None: + client, repository = daily_activity_client + missing_dates: Final = client.get("/user/daily/activity/aggregated", params={"user_id": "user-a"}) + assert missing_dates.status_code == 400, missing_dates.text + assert missing_dates.json()["detail"] == {"error": "Please provide start_date and end_date"} + repository.aggregated.assert_not_awaited() + + +@pytest.mark.parametrize( + ("start_date", "end_date", "message"), + ( + ("2020-01-01", "2026-12-31", "at most 400 days"), + ("not-a-date", "2024-01-31", "valid YYYY-MM-DD"), + ("2024-06-01", "2024-01-01", "on or after"), + ), +) +def test_user_key_page_rejects_bad_date_ranges( + daily_activity_client: tuple[TestClient, _FakeRepository], + start_date: str, + end_date: str, + message: str, +) -> None: + client, repository = daily_activity_client + response: Final = client.get( + "/user/daily/activity/aggregated/keys", + params={"start_date": start_date, "end_date": end_date, "user_id": "user-a"}, + ) + assert response.status_code == 400, response.text + assert message in str(response.json()["detail"]), response.text + repository.key_page.assert_not_awaited() diff --git a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py index be8628d7f46..799fe59147c 100644 --- a/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_internal_user_endpoints.py @@ -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): """ diff --git a/tests/unit/proxy/management_endpoints/test_organization_endpoints.py b/tests/unit/proxy/management_endpoints/test_organization_endpoints.py index 48586874788..440f93d1387 100644 --- a/tests/unit/proxy/management_endpoints/test_organization_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_organization_endpoints.py @@ -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 diff --git a/tests/unit/proxy/management_endpoints/test_team_endpoints.py b/tests/unit/proxy/management_endpoints/test_team_endpoints.py index 764193c9650..7e8e588fec6 100644 --- a/tests/unit/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_team_endpoints.py @@ -1,10 +1,10 @@ import asyncio import json +from collections.abc import Sequence from contextlib import AbstractContextManager, asynccontextmanager, contextmanager from dataclasses import dataclass from datetime import datetime, timezone from types import SimpleNamespace -from collections.abc import Sequence from typing import Final, Optional, cast from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch @@ -14602,129 +14602,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) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 7bce039cd3e..a240b4ed84a 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -834,6 +834,91 @@ export interface paths { patch?: never; trace?: never; }; + "/agent/daily/activity/aggregated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Agent Daily Activity Aggregated */ + get: operations["get_agent_daily_activity_aggregated_agent_daily_activity_aggregated_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agent/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Agent Daily Activity Aggregated Keys */ + get: operations["get_agent_daily_activity_aggregated_keys_agent_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agent/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Agent Daily Activity Model Top Keys */ + get: operations["get_agent_daily_activity_model_top_keys_agent_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agent/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Agent Daily Activity Aggregated Search */ + get: operations["get_agent_daily_activity_aggregated_search_agent_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/agent/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Agent Daily Activity Export */ + get: operations["get_agent_daily_activity_export_agent_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/alerting/settings": { parameters: { query?: never; @@ -3982,6 +4067,91 @@ export interface paths { patch?: never; trace?: never; }; + "/customer/daily/activity/aggregated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Aggregated */ + get: operations["get_customer_daily_activity_aggregated_customer_daily_activity_aggregated_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/customer/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Aggregated Keys */ + get: operations["get_customer_daily_activity_aggregated_keys_customer_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/customer/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Model Top Keys */ + get: operations["get_customer_daily_activity_model_top_keys_customer_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/customer/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Aggregated Search */ + get: operations["get_customer_daily_activity_aggregated_search_customer_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/customer/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Export */ + get: operations["get_customer_daily_activity_export_customer_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/customer/delete": { parameters: { query?: never; @@ -4542,6 +4712,91 @@ export interface paths { patch?: never; trace?: never; }; + "/end_user/daily/activity/aggregated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Aggregated */ + get: operations["get_customer_daily_activity_aggregated_end_user_daily_activity_aggregated_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/end_user/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Aggregated Keys */ + get: operations["get_customer_daily_activity_aggregated_keys_end_user_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/end_user/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Model Top Keys */ + get: operations["get_customer_daily_activity_model_top_keys_end_user_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/end_user/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Aggregated Search */ + get: operations["get_customer_daily_activity_aggregated_search_end_user_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/end_user/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Customer Daily Activity Export */ + get: operations["get_customer_daily_activity_export_end_user_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/end_user/delete": { parameters: { query?: never; @@ -11093,6 +11348,91 @@ export interface paths { patch?: never; trace?: never; }; + "/organization/daily/activity/aggregated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Organization Daily Activity Aggregated */ + get: operations["get_organization_daily_activity_aggregated_organization_daily_activity_aggregated_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organization/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Organization Daily Activity Aggregated Keys */ + get: operations["get_organization_daily_activity_aggregated_keys_organization_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organization/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Organization Daily Activity Model Top Keys */ + get: operations["get_organization_daily_activity_model_top_keys_organization_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organization/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Organization Daily Activity Aggregated Search */ + get: operations["get_organization_daily_activity_aggregated_search_organization_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organization/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Organization Daily Activity Export */ + get: operations["get_organization_daily_activity_export_organization_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/organization/delete": { parameters: { query?: never; @@ -15647,6 +15987,91 @@ export interface paths { patch?: never; trace?: never; }; + "/tag/daily/activity/aggregated": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Tag Daily Activity Aggregated */ + get: operations["get_tag_daily_activity_aggregated_tag_daily_activity_aggregated_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tag/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Tag Daily Activity Aggregated Keys */ + get: operations["get_tag_daily_activity_aggregated_keys_tag_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tag/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Tag Daily Activity Model Top Keys */ + get: operations["get_tag_daily_activity_model_top_keys_tag_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tag/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Tag Daily Activity Aggregated Search */ + get: operations["get_tag_daily_activity_aggregated_search_tag_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/tag/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Tag Daily Activity Export */ + get: operations["get_tag_daily_activity_export_tag_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tag/dau": { parameters: { query?: never; @@ -16138,6 +16563,74 @@ export interface paths { patch?: never; trace?: never; }; + "/team/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Team Daily Activity Aggregated Keys */ + get: operations["get_team_daily_activity_aggregated_keys_team_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/team/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Team Daily Activity Model Top Keys */ + get: operations["get_team_daily_activity_model_top_keys_team_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/team/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Team Daily Activity Aggregated Search */ + get: operations["get_team_daily_activity_aggregated_search_team_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/team/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Team Daily Activity Export */ + get: operations["get_team_daily_activity_export_team_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/team/delete": { parameters: { query?: never; @@ -17819,6 +18312,91 @@ export interface paths { patch?: never; trace?: never; }; + "/user/daily/activity/aggregated/cache_leakage_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Daily Activity Cache Leakage Keys */ + get: operations["get_user_daily_activity_cache_leakage_keys_user_daily_activity_aggregated_cache_leakage_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/daily/activity/aggregated/keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Daily Activity Aggregated Keys */ + get: operations["get_user_daily_activity_aggregated_keys_user_daily_activity_aggregated_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/daily/activity/aggregated/model_top_keys": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Daily Activity Model Top Keys */ + get: operations["get_user_daily_activity_model_top_keys_user_daily_activity_aggregated_model_top_keys_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Daily Activity Aggregated Search */ + get: operations["get_user_daily_activity_aggregated_search_user_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/daily/activity/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Daily Activity Export */ + get: operations["get_user_daily_activity_export_user_daily_activity_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/user/delete": { parameters: { query?: never; @@ -27151,6 +27729,11 @@ export interface components { */ source: "provider_usage"; }; + /** CacheLeakageKeysResponse */ + CacheLeakageKeysResponse: { + /** Api Keys */ + api_keys: components["schemas"]["KeySpendActivityRow"][]; + }; /** CachePingResponse */ CachePingResponse: { /** Cache Type */ @@ -30170,6 +30753,22 @@ export interface components { */ ssl_verify?: string | null; }; + /** DailyActivityKeyPageResponse */ + DailyActivityKeyPageResponse: { + /** Api Keys */ + api_keys: components["schemas"]["KeySpendActivityRow"][]; + /** Limit */ + limit: number; + /** Offset */ + offset: number; + /** Total Api Keys */ + total_api_keys: number; + }; + /** DailyActivityKeySearchResponse */ + DailyActivityKeySearchResponse: { + /** Api Keys */ + api_keys: components["schemas"]["KeyActivityRow"][]; + }; /** DailySpendData */ DailySpendData: { breakdown?: components["schemas"]["BreakdownMetrics"]; @@ -30977,6 +31576,11 @@ export interface components { /** Parts */ parts: components["schemas"]["TracePart"][]; }; + /** + * ExportType + * @enum {string} + */ + ExportType: "daily" | "daily_with_keys" | "daily_with_models" | "daily_with_users"; /** * FacetListResponse * @description The distinct values one column takes over a filtered query. `data` holds bare values, not entity rows. @@ -32564,6 +33168,13 @@ export interface components { worker_id?: string | null; }; JsonValue: unknown; + /** KeyActivityRow */ + KeyActivityRow: { + /** Api Key */ + api_key: string; + metadata: components["schemas"]["KeyMetadata"]; + metrics: components["schemas"]["SpendMetrics"]; + }; /** KeyHealthResponse */ KeyHealthResponse: { /** @@ -32626,6 +33237,61 @@ export interface components { /** Keys */ keys?: string[] | null; }; + /** KeySpendActivityRow */ + KeySpendActivityRow: { + /** Api Key */ + api_key: string; + metadata: components["schemas"]["KeyMetadata"]; + metrics: components["schemas"]["KeySpendMetrics"]; + }; + /** KeySpendMetrics */ + KeySpendMetrics: { + /** + * Api Requests + * @default 0 + */ + api_requests: number; + /** + * Cache Creation Input Tokens + * @default 0 + */ + cache_creation_input_tokens: number; + /** + * Cache Read Input Tokens + * @default 0 + */ + cache_read_input_tokens: number; + /** + * Completion Tokens + * @default 0 + */ + completion_tokens: number; + /** + * Failed Requests + * @default 0 + */ + failed_requests: number; + /** + * Prompt Tokens + * @default 0 + */ + prompt_tokens: number; + /** + * Spend + * @default 0 + */ + spend: number; + /** + * Successful Requests + * @default 0 + */ + successful_requests: number; + /** + * Total Tokens + * @default 0 + */ + total_tokens: number; + }; /** * KeyUpdateFields * @description Allowlist of bulk-broadcastable fields for /team/key/bulk_update; `extra="forbid"` blocks RBAC/ownership/scope mutations even by team admins. @@ -37312,6 +37978,15 @@ export interface components { /** Cost */ cost: number; }; + /** ModelTopKeysResponse */ + ModelTopKeysResponse: { + /** Api Keys */ + api_keys: components["schemas"]["KeySpendActivityRow"][]; + /** By Model Group */ + by_model_group: boolean; + /** Model */ + model: string; + }; /** * Move * @description A mouse move action. @@ -49724,6 +50399,202 @@ export interface operations { }; }; }; + get_agent_daily_activity_aggregated_agent_daily_activity_aggregated_get: { + parameters: { + query?: { + api_key_limit?: number; + agent_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_agent_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_agent_daily_activity_aggregated_keys_agent_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + agent_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_agent_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_agent_daily_activity_model_top_keys_agent_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + agent_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_agent_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_agent_daily_activity_aggregated_search_agent_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + agent_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_agent_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_agent_daily_activity_export_agent_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + agent_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_agent_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; alerting_settings_alerting_settings_get: { parameters: { query?: never; @@ -54767,6 +55638,202 @@ export interface operations { }; }; }; + get_customer_daily_activity_aggregated_customer_daily_activity_aggregated_get: { + parameters: { + query?: { + api_key_limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_aggregated_keys_customer_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_model_top_keys_customer_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_aggregated_search_customer_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_export_customer_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_end_user_customer_delete_post: { parameters: { query?: never; @@ -55364,6 +56431,202 @@ export interface operations { }; }; }; + get_customer_daily_activity_aggregated_end_user_daily_activity_aggregated_get: { + parameters: { + query?: { + api_key_limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_aggregated_keys_end_user_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_model_top_keys_end_user_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_aggregated_search_end_user_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_customer_daily_activity_export_end_user_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + end_user_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_end_user_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_end_user_end_user_delete_post: { parameters: { query?: never; @@ -64202,6 +65465,202 @@ export interface operations { }; }; }; + get_organization_daily_activity_aggregated_organization_daily_activity_aggregated_get: { + parameters: { + query?: { + api_key_limit?: number; + organization_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_organization_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_organization_daily_activity_aggregated_keys_organization_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + organization_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_organization_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_organization_daily_activity_model_top_keys_organization_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + organization_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_organization_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_organization_daily_activity_aggregated_search_organization_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + organization_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_organization_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_organization_daily_activity_export_organization_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + organization_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_organization_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_organization_organization_delete_delete: { parameters: { query?: never; @@ -69246,6 +70705,197 @@ export interface operations { }; }; }; + get_tag_daily_activity_aggregated_tag_daily_activity_aggregated_get: { + parameters: { + query?: { + api_key_limit?: number; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + tags?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_tag_daily_activity_aggregated_keys_tag_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + tags?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_tag_daily_activity_model_top_keys_tag_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + tags?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_tag_daily_activity_aggregated_search_tag_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + tags?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_tag_daily_activity_export_tag_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + tags?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_daily_active_users_tag_dau_get: { parameters: { query?: { @@ -69748,6 +71398,7 @@ export interface operations { get_team_daily_activity_aggregated_team_daily_activity_aggregated_get: { parameters: { query?: { + api_key_limit?: number; team_ids?: string | null; start_date?: string | null; end_date?: string | null; @@ -69782,6 +71433,164 @@ export interface operations { }; }; }; + get_team_daily_activity_aggregated_keys_team_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + team_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_team_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_team_daily_activity_model_top_keys_team_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + team_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_team_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_team_daily_activity_aggregated_search_team_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + team_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_team_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_team_daily_activity_export_team_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + team_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + model?: string | null; + api_key?: string | null; + exclude_team_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_team_team_delete_post: { parameters: { query?: never; @@ -71871,6 +73680,7 @@ export interface operations { get_user_daily_activity_aggregated_user_daily_activity_aggregated_get: { parameters: { query?: { + api_key_limit?: number; /** @description Start date in YYYY-MM-DD format */ start_date?: string | null; /** @description End date in YYYY-MM-DD format */ @@ -71912,6 +73722,237 @@ export interface operations { }; }; }; + get_user_daily_activity_cache_leakage_keys_user_daily_activity_aggregated_cache_leakage_keys_get: { + parameters: { + query?: { + limit?: number; + /** @description Start date in YYYY-MM-DD format */ + start_date?: string | null; + /** @description End date in YYYY-MM-DD format */ + end_date?: string | null; + /** @description Filter by specific model */ + model?: string | null; + /** @description Filter by specific API key */ + api_key?: string | null; + /** @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. */ + user_id?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ + timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CacheLeakageKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_daily_activity_aggregated_keys_user_daily_activity_aggregated_keys_get: { + parameters: { + query?: { + offset?: number; + limit?: number; + /** @description Start date in YYYY-MM-DD format */ + start_date?: string | null; + /** @description End date in YYYY-MM-DD format */ + end_date?: string | null; + /** @description Filter by specific model */ + model?: string | null; + /** @description Filter by specific API key */ + api_key?: string | null; + /** @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. */ + user_id?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ + timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeyPageResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_daily_activity_model_top_keys_user_daily_activity_aggregated_model_top_keys_get: { + parameters: { + query: { + model_group: string; + by_model_group?: boolean; + limit?: number; + /** @description Start date in YYYY-MM-DD format */ + start_date?: string | null; + /** @description End date in YYYY-MM-DD format */ + end_date?: string | null; + /** @description Filter by specific model */ + model?: string | null; + /** @description Filter by specific API key */ + api_key?: string | null; + /** @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. */ + user_id?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ + timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ModelTopKeysResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_daily_activity_aggregated_search_user_daily_activity_aggregated_search_get: { + parameters: { + query: { + search: string; + limit?: number; + /** @description Start date in YYYY-MM-DD format */ + start_date?: string | null; + /** @description End date in YYYY-MM-DD format */ + end_date?: string | null; + /** @description Filter by specific model */ + model?: string | null; + /** @description Filter by specific API key */ + api_key?: string | null; + /** @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. */ + user_id?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ + timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DailyActivityKeySearchResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_daily_activity_export_user_daily_activity_export_get: { + parameters: { + query?: { + export_type?: components["schemas"]["ExportType"]; + format?: "csv" | "json"; + /** @description Start date in YYYY-MM-DD format */ + start_date?: string | null; + /** @description End date in YYYY-MM-DD format */ + end_date?: string | null; + /** @description Filter by specific model */ + model?: string | null; + /** @description Filter by specific API key */ + api_key?: string | null; + /** @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. */ + user_id?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ + timezone?: number | null; + /** @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. */ + include_current_utc_day?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Streamed daily activity export */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": Record[]; + "text/csv": string; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_user_user_delete_post: { parameters: { query?: never; From ee3eff26cb1b42acffa69508bb7ac978fffdab4d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:03:09 -0700 Subject: [PATCH 3/4] feat(ui): usage pages consume bounded daily activity routes (#43409) Resolves LIT-8899 Usage, cost optimization, user and team pages read the aggregate, paginated key, search, model top key, cache leakage and export routes added by the lower layers instead of downloading every key's daily rows into the browser. Key detail, model top key and search failures render explicit errors with retry controls, Overall Usage shows a loader while the aggregate is in flight, Retry on a failed first key page shows the loading state while it refetches, a short query keeps the loader or first page error visible instead of No keys match, key paging advances by the server offset so a page of already loaded keys moves on and only an empty page ends paging, and search results are stored as rows so a new teams array from the parent does not restart an in-flight search, and the global Cost tab shows a loader instead of zero totals while the aggregate reloads. Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/eslint-suppressions.json | 19 +- .../_components/CacheLeakageCard.test.tsx | 153 +- .../_components/CacheLeakageCard.tsx | 37 +- .../CostOptimizationView.activity.test.tsx | 30 +- .../_components/CostOptimizationView.test.tsx | 7 +- .../_components/CostOptimizationView.tsx | 17 +- .../_components/PromptCachingTab.test.tsx | 14 +- .../_components/UsageTab.test.tsx | 13 +- .../_components/UsageTab.tsx | 4 +- .../_components/costOptimizationUtils.ts | 90 +- .../_components/useCacheLeakageKeys.ts | 66 + .../useDailyActivityRange.test.tsx | 82 +- .../_components/useDailyActivityRange.ts | 88 +- .../EntityUsage/EntityUsage.test.tsx | 258 +- .../components/EntityUsage/EntityUsage.tsx | 248 +- .../components/EntityUsage/entityFetchFns.ts | 52 + .../components/UsagePageView.test.tsx | 195 +- .../_components/components/UsagePageView.tsx | 267 +- .../hooks/useAggregatedDailyActivity.test.ts | 71 + .../hooks/useAggregatedDailyActivity.ts | 65 + .../hooks/usePaginatedDailyActivity.test.ts | 321 -- .../hooks/usePaginatedDailyActivity.ts | 374 -- .../user_info_view.integration.test.tsx | 57 +- .../EntityUsageExportModal.test.tsx | 102 +- .../EntityUsageExportModal.tsx | 70 +- .../ExportTypeSelector.test.tsx | 2 +- .../EntityUsageExport/ExportTypeSelector.tsx | 10 +- .../UsageExportHeader.test.tsx | 33 +- .../EntityUsageExport/UsageExportHeader.tsx | 20 +- .../exportBlockedReason.test.ts | 34 - .../EntityUsageExport/exportBlockedReason.ts | 13 - .../src/components/EntityUsageExport/types.ts | 60 +- .../EntityUsageExport/utils.test.ts | 3037 +---------------- .../src/components/EntityUsageExport/utils.ts | 496 +-- .../KeyActivityPanel.integration.test.tsx | 674 ++++ .../components/KeyActivityPanel.test.tsx | 71 - .../UsagePage/components/KeyActivityPanel.tsx | 421 ++- .../UsagePage/components/keySearch.ts | 33 + .../components/UsagePage/dailyActivityApi.ts | 101 + .../UsagePage/keyActivityData.test.ts | 154 + .../components/UsagePage/keyActivityData.ts | 61 + .../UsagePage/keyActivityFilter.test.ts | 1 - .../src/components/UsagePage/types.ts | 11 - .../src/components/activity_metrics.test.tsx | 335 +- .../src/components/activity_metrics.tsx | 319 +- .../src/components/chat/UsagePanel.tsx | 12 +- .../networking.dailyActivity.test.ts | 224 ++ .../src/components/networking.test.ts | 136 - .../src/components/networking.tsx | 372 +- .../shared/PaginationStatusAlerts.test.tsx | 108 - .../shared/PaginationStatusAlerts.tsx | 63 - .../components/shared/ScopedSavingsTab.tsx | 10 +- .../KeySavingsTab.integration.test.tsx | 13 +- .../src/lib/http/client.test.ts | 19 + ui/litellm-dashboard/src/lib/http/client.ts | 14 +- 55 files changed, 3394 insertions(+), 6163 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useCacheLeakageKeys.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts delete mode 100644 ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts delete mode 100644 ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts delete mode 100644 ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts create mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.integration.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx create mode 100644 ui/litellm-dashboard/src/components/UsagePage/components/keySearch.ts create mode 100644 ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts create mode 100644 ui/litellm-dashboard/src/components/UsagePage/keyActivityData.test.ts create mode 100644 ui/litellm-dashboard/src/components/UsagePage/keyActivityData.ts create mode 100644 ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts delete mode 100644 ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index daf12d11743..2465d07129c 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx index 8d328c4c329..95abc4e22cb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.test.tsx @@ -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: () =>
, @@ -11,8 +20,9 @@ vi.mock("@/components/shared/advanced_date_picker", () => ({ import CacheLeakageCard from "./CacheLeakageCard"; -const baseMetrics = (overrides: Partial): SpendMetrics => ({ +const baseMetrics = (overrides: Partial): 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 => ({ 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): KeyMetricWithMetadata => ({ +const keyRow = (hash: string, alias: string, metrics: Partial): KeySpendActivityRow => ({ + api_key: hash, metrics: baseMetrics(metrics), metadata: { key_alias: alias, team_id: null }, }); -const dayWithKeys = (date: string, apiKeys: Record): DailyData => ({ - date, - metrics: baseMetrics({}), - breakdown: { - models: {}, - model_groups: {}, - mcp_servers: {}, - providers: {}, - api_keys: apiKeys, - entities: {}, - }, -}); - const dayWithModels = (date: string, models: Record>): DailyData => ({ date, metrics: baseMetrics({}), @@ -67,27 +72,37 @@ const renderWith = (results: DailyData[], overrides: Partial 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(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx index cfac77788b7..7a63ccd1ee4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CacheLeakageCard.tsx @@ -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 = ({ activity }) => { - const { results, loading, isFetchingMore } = activity; + const { results, loading } = activity; const [dimension, setDimension] = useState("key"); const [sort, setSort] = useState({ 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 = ({ 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 ( @@ -119,14 +141,9 @@ const CacheLeakageCard: React.FC = ({ activity }) => { - {rows.length > 0 && isFetchingMore && ( -

- Data is still loading; rows and totals will update as the rest of the range arrives. -

- )} {rows.length === 0 ? (

- {loading || isFetchingMore ? "Loading..." : `No ${emptyNoun} usage in this range.`} + {rowsLoading ? "Loading..." : emptyMessage}

) : ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx index f8336f5ab56..204c4b1a409 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.activity.test.tsx @@ -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", () => { , ); - 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", () => { , ); - 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(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx index 028367555a1..a2f0ca5edc9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx @@ -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: () =>
})); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx index 25bd3de0382..0aa4f88495a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx @@ -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 = ({ accessToken

- + {activity.failed && ( + + + Fetching spend data failed, so the savings below may be empty rather than final. Reload the page to try + again. + + + )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx index 35464c5852e..a4a06cb6db0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx @@ -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(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx index c62208aacc5..1d88e25396b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx @@ -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, + }, }} />, ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx index 83b202590cb..2d673d96296 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx @@ -44,7 +44,7 @@ const EMPTY_TOOL_SPEND: ToolSpendResponse = { const isoDay = (d: Date): string => d.toISOString().slice(0, 10); const UsageTab: React.FC = ({ 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 = ({ accessToken, activity }) => { - +
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts index 5f16b1b04fd..b95e7e0d973 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/costOptimizationUtils.ts @@ -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 { + 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 { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useCacheLeakageKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useCacheLeakageKeys.ts new file mode 100644 index 00000000000..4febd774eb8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useCacheLeakageKeys.ts @@ -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(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, + }; +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx index b94fa45ecb7..90d6694ccba 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.test.tsx @@ -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; + 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, + }); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts index 605926132e9..1af009397b1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/useDailyActivityRange.ts @@ -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; @@ -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( + () => + 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 }, }; }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index 8f5bb9455d0..23bd772729b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -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; + }) => (
Activity Metrics {`metrics-source:${modelMetrics?.__source ?? "none"}`} + {summaryMetrics !== undefined && {summaryTitle}} + {fetchTopApiKeys !== undefined && {`top-keys-fetcher:${modelMetrics?.__source ?? "none"}`}}
), 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: () =>
Team Multi Select
, + default: ({ onChange }: { onChange: (value: string[]) => void }) => ( + + ), })); // 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> = { + 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 + )(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(); 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(); + + 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(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); act(() => { @@ -878,7 +944,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); expect(screen.getByText("Agent Activity")).toBeInTheDocument(); @@ -898,7 +964,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); expect(screen.getByText("Top Agents Driving Spend")).toBeInTheDocument(); @@ -918,16 +984,56 @@ describe("EntityUsage", () => { render(); 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(); + + 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((resolve) => { + resolveSpend = resolve; + }), + ); + render(); + + 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(); @@ -942,7 +1048,7 @@ describe("EntityUsage", () => { render(); await waitFor(() => { - expect(mockTeamDailyActivityAggregatedCall).toHaveBeenCalled(); + expect(mockTeamDailyActivityCall).toHaveBeenCalled(); }); const agentActivityTab = screen.getByText("Agent Activity"); @@ -1098,7 +1204,7 @@ describe("EntityUsage", () => { render(); 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(); 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(); 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(); + + 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(); + + 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 }), ); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 6687bd4df03..3f239250967 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -6,7 +6,6 @@ import { getTopAgents, getTopAPIKeys, getTopModels, - type ExtendedDailyData, type ProviderSpendRow, } from "./entityUsageAggregations"; import { buildCostBreakdownTiles, buildSummaryTiles, hasFlatCost, type SummaryTile } from "./entityUsageSummary"; @@ -17,27 +16,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"; @@ -62,18 +59,6 @@ interface EntityMetrics { metadata: Record; } -interface EntitySpendData { - results: ExtendedDailyData[]; - metadata: { - total_spend: number; - total_flat_cost?: number; - total_api_requests: number; - total_successful_requests: number; - total_failed_requests: number; - total_tokens: number; - }; -} - export interface EntityList { label: string; value: string; @@ -91,21 +76,6 @@ interface EntityUsageProps { isOrgAdmin?: boolean; } -const ENTITY_FETCH_FNS: Record Promise> = { - 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 Promise>> = { - team: teamDailyActivityAggregatedCall, -}; - const ENTITY_CAPABILITIES: Partial> = { organization: "viewOrganizationUsage", agent: "viewAgentUsage", @@ -121,6 +91,7 @@ const EntityUsage: React.FC = ({ isOrgAdmin = false, }) => { const { teams } = useTeams(); + const teamList = useMemo(() => teams ?? [], [teams]); const [selectedTags, setSelectedTags] = useState([]); const [modelViewType, setModelViewType] = useState("groups"); const [topKeysLimit, setTopKeysLimit] = useState(5); @@ -130,56 +101,103 @@ const EntityUsage: React.FC = ({ 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( + () => + 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( + () => + 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) { @@ -393,7 +411,9 @@ const EntityUsage: React.FC = ({ const modelViewTitle = modelViewType === "groups" ? "Top Public Model Names" : "Top Litellm Models"; - const costPanel = ( + const costPanel = loading ? ( + + ) : (
@@ -594,11 +614,15 @@ const EntityUsage: React.FC = ({

Top Agents Driving Spend

- + {agentLoading ? ( + + ) : ( + + )}
@@ -649,46 +673,67 @@ const EntityUsage: React.FC = ({
- + ), }, ...(showAgentBreakdown - ? [{ key: "agents", label: "Agent Activity", content: }] + ? [ + { + key: "agents", + label: "Agent Activity", + content: , + }, + ] : []), { key: "keys", label: "Key Activity", - content: , + content: ( + + ), }, { key: "endpoints", label: "Endpoint Activity", content: }, ]; - const spendFetchState = { coversRange, cancelled, failed }; - return (
- - {showAgentBreakdown && ( - + {failed && ( + + + Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try + again. + + + )} + {showAgentBreakdown && agentFailed && ( + + + Fetching agent data failed, so the totals below may be empty rather than final. Reload the page to try + again. + + )} + 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)} @@ -696,8 +741,7 @@ const EntityUsage: React.FC = ({ selectedFilters={selectedTags} onFiltersChange={setSelectedTags} filterOptions={getAllTags() || undefined} - teams={teams || []} - exportBlockedReason={getExportBlockedReason(spendFetchState)} + teams={teamList} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns.ts new file mode 100644 index 00000000000..6dc80ef4591 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/entityFetchFns.ts @@ -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; + keyPage(req: DailyActivityRequest, offset: number, limit: number): Promise; + searchKeys(req: DailyActivityRequest, search: string, limit?: number): Promise; + modelTopKeys( + req: DailyActivityRequest, + model: string, + byModelGroup: boolean, + limit?: number, + ): Promise; + exportRows(req: DailyActivityRequest, exportType: ExportType, format: ExportFormat): Promise; +} + +const entityApi = ( + entity: DailyActivityEntity, + defaults?: Pick, +): 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 = { + tag: entityApi("tag"), + team: entityApi("team", { excludeEntityIds: ["litellm-dashboard"] }), + organization: entityApi("organization"), + customer: entityApi("customer"), + agent: entityApi("agent"), + user: entityApi("user"), +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index 7702488f5bf..41873ca554b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -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) => 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(); + + 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(); 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(); 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(); - 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(); - - 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(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 018dfc84740..6f18b2fe1d2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -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 ? :

{children}

; + const UsagePage: React.FC = ({ teams, organizations }) => { const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); - // Aggregated endpoint: try first, fall back to paginated if unavailable - const [aggregatedData, setAggregatedData] = useState | 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 | null>(null); - const [aggregatedLoading, setAggregatedLoading] = useState(false); const [gatewayActivityData, setGatewayActivityData] = useState(null); // Separate loading states for better UX @@ -142,7 +135,6 @@ const UsagePage: React.FC = ({ 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 = ({ 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( + () => + 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 = ({ 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 = ({ 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 (
{/* Global Date Picker and Tabs - Single Row */} @@ -453,13 +460,14 @@ const UsagePage: React.FC = ({ teams, organizations }) => { />
- + {aggregatedFailed && ( + + + Fetching spend data failed, so the totals below may be empty rather than final. Reload the page to try + again. + + + )} {/* Your Usage / Global Usage Panel */} {(usageView === "global" || usageView === "my-usage") && ( <> @@ -493,16 +501,10 @@ const UsagePage: React.FC = ({ teams, organizations }) => { Ask AI - - - +
{/* Cost Panel */} @@ -532,11 +534,13 @@ const UsagePage: React.FC = ({ teams, organizations }) => {

- + {!loading && ( + + )}
@@ -547,12 +551,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => {

Total Requests

-

+ {(gatewayActivity ? gatewayActivity.total_successful_requests + gatewayActivity.total_failed_requests : userSpendData.metadata?.total_api_requests )?.toLocaleString() || 0} -

+
@@ -577,12 +581,15 @@ const UsagePage: React.FC = ({ teams, organizations }) => { today: a non-admin (who may not read deployment-wide counts) and an admin on a proxy whose table is still backfilling. */} -

+ {( gatewayActivity?.total_successful_requests ?? userSpendData.metadata?.total_successful_requests )?.toLocaleString() || 0} -

+
@@ -602,24 +609,27 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
{/* Same source as Successful Requests: the two must agree, or the tile disagrees with the endpoint breakdown chart below it. */} -

+ {( gatewayActivity?.total_failed_requests ?? userSpendData.metadata?.total_failed_requests )?.toLocaleString() || 0} -

+

Average Cost per Request

-

+ $ {formatNumberWithCommas( (totalSpend || 0) / (userSpendData.metadata?.total_api_requests || 1), 4, )} -

+
= ({ teams, organizations }) => { )} -

+ {userSpendData.metadata?.total_tokens?.toLocaleString() || 0} -

+
@@ -646,33 +656,33 @@ const UsagePage: React.FC = ({ teams, organizations }) => {

Input Tokens

-

+ {(userSpendData.metadata?.total_prompt_tokens || 0).toLocaleString()} -

+

Output Tokens

-

+ {userSpendData.metadata?.total_completion_tokens?.toLocaleString() || 0} -

+

Cache Read Tokens

-

+ {userSpendData.metadata?.total_cache_read_input_tokens?.toLocaleString() || 0} -

+

Cache Write Tokens

-

+ {userSpendData.metadata?.total_cache_creation_input_tokens?.toLocaleString() || 0} -

+
@@ -858,10 +868,20 @@ const UsagePage: React.FC = ({ teams, organizations }) => {
- + - + @@ -1004,11 +1024,12 @@ const UsagePage: React.FC = ({ teams, organizations }) => { 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" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts new file mode 100644 index 00000000000..16a45d03db9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.test.ts @@ -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((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); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts new file mode 100644 index 00000000000..08aec2e77f2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/useAggregatedDailyActivity.ts @@ -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; + 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(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, + }; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts deleted file mode 100644 index 0b8cfecbfc6..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.test.ts +++ /dev/null @@ -1,321 +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_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).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); - }); -}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts deleted file mode 100644 index 1f03f6a4fcb..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/hooks/usePaginatedDailyActivity.ts +++ /dev/null @@ -1,374 +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_response_time_ms", - "total_timed_requests", -] as const; - -interface DailyActivityResponse { - results: DailyData[]; - metadata: Record; -} - -type FetchPageFn = (...args: any[]) => Promise; - -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; -} - -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, b: Record): Record { - 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 = ( - a: Record | undefined, - b: Record | undefined, - mergeEntry: (left: T, right: T) => T, -): Record => { - 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( - (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(EMPTY_DATA); - const [loading, setLoading] = useState(false); - const [isFetchingMore, setIsFetchingMore] = useState(false); - const [progress, setProgress] = useState({ - currentPage: 0, - totalPages: 0, - }); - const [cancelled, setCancelled] = useState(false); - const [failed, setFailed] = useState(false); - const [completedKey, setCompletedKey] = useState(null); - - const fetchIdRef = useRef(0); - const cancelledRef = useRef(false); - const delayTimerRef = useRef | 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((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 }; -} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx index 6a0e55a6dda..676bc7d29eb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/user_info_view.integration.test.tsx @@ -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(); 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(); 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(); 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(); 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(); }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx index cb04323e4c9..375acd281af 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.test.tsx @@ -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(); - // 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(); - // 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(); + + await user.click(screen.getByRole("button", { name: /Export CSV/i })); + + expect(baseProps.onExport).toHaveBeenCalled(); + expect(downloadBlob).not.toHaveBeenCalled(); + expect(baseProps.onClose).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx index ec688e9fc3d..7044e3a7f31 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/EntityUsageExportModal.tsx @@ -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 = ({ isOpen, onClose, entityType, - spendData, + onExport, dateRange, - selectedFilters, + selectedFilters = [], customTitle, }) => { const [exportFormat, setExportFormat] = useState("csv"); - const [exportScope, setExportScope] = useState("daily"); + const [exportType, setExportType] = useState("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 = ({ {modalTitle}
- {isLoadingTeams ? ( -
- - - -
- ) : ( - <> - - - - - )} + + +
- {isLoadingTeams ? ( - <> - - - - ) : ( - <> - - - - )} + +
diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx index 6743167bff6..997c048048f 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.test.tsx @@ -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", () => { diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx index f6fdece83a8..3162f00c056 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/ExportTypeSelector.tsx @@ -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 = ({ 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 = ({ value, onChange return (
- onChange(next as ExportScope)} className="gap-2"> + onChange(next as ExportType)} className="gap-2"> {scopes.map((scope) => (
@@ -137,7 +133,7 @@ const UsageExportHeader: React.FC = ({ isOpen={isExportModalOpen} onClose={() => setIsExportModalOpen(false)} entityType={entityType} - spendData={spendData} + onExport={onExport} dateRange={dateValue} selectedFilters={selectedFilters} customTitle={customTitle} diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts deleted file mode 100644 index e39b01a5dea..00000000000 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { getExportBlockedReason, type UsageFetchState } from "./exportBlockedReason"; - -const state = (overrides: Partial = {}): 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); - }); -}); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts deleted file mode 100644 index 71408ba8f3f..00000000000 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/exportBlockedReason.ts +++ /dev/null @@ -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; -}; diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts index 15f193ecc3f..189a14392ec 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/types.ts @@ -1,67 +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_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; 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_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; - }; -} diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts index 3f9cf58ec20..172d772e461 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.test.ts @@ -1,3008 +1,51 @@ -// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; -import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; -import Papa from "papaparse"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { EntitySpendData, ExportScope } from "./types"; -import { - generateDailyData, - generateDailyWithKeysData, - generateDailyWithModelsData, - generateDailyWithUsersData, - generateExportData, - generateMetadata, - getEntityBreakdown, - handleExportCSV, - handleExportJSON, - resolveEntities, -} from "./utils"; +import { downloadBlob, exportFilename } from "./utils"; -vi.mock("@/utils/dataUtils", () => ({ - formatNumberWithCommas: vi.fn((value: number, decimals: number = 0) => { - if (value === null || value === undefined || !Number.isFinite(value)) { - return "-"; - } - return value.toFixed(decimals); - }), -})); +describe("exportFilename", () => { + it("composes entity, export type, range and format", () => { + const name = exportFilename("team", "daily_with_keys", "csv", { + from: new Date(2025, 0, 5), + to: new Date(2025, 0, 31), + }); -vi.mock("papaparse", () => ({ - default: { - unparse: vi.fn((data: any[]) => "mocked-csv-data"), - }, -})); - -describe("EntityUsageExport utils", () => { - // Entity keys match team_ids because that's how the backend shapes team exports - // (breakdown.entities is keyed by team_id). The fix under test uses the entity key - // directly for display, so the key_alias/team_id in api_key_breakdown metadata is - // no longer consulted — it's retained here only to mirror real payload shape. - const mockSpendData: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - "team-2": { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 190, - failed_requests: 10, - total_tokens: 2000, - prompt_tokens: 1200, - completion_tokens: 800, - cache_read_input_tokens: 100, - cache_creation_input_tokens: 60, - }, - api_key_breakdown: { - key2: { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 190, - failed_requests: 10, - total_tokens: 2000, - }, - metadata: { - team_id: "team-2", - key_alias: "alias-2", - }, - }, - }, - }, - }, - }, - }, - { - date: "2025-01-02", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 15.2, - api_requests: 150, - successful_requests: 145, - failed_requests: 5, - total_tokens: 1500, - prompt_tokens: 900, - completion_tokens: 600, - cache_read_input_tokens: 75, - cache_creation_input_tokens: 45, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 15.2, - api_requests: 150, - successful_requests: 145, - failed_requests: 5, - total_tokens: 1500, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 46.0, - total_api_requests: 450, - total_successful_requests: 430, - total_failed_requests: 20, - total_tokens: 4500, - }, - }; - - const mockTeamAliasMap: Record = { - "team-1": "Team One", - "team-2": "Team Two", - }; - - const usersFixture: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 16.5, - api_requests: 165, - successful_requests: 156, - failed_requests: 9, - total_tokens: 1650, - prompt_tokens: 940, - completion_tokens: 710, - cache_read_input_tokens: 90, - cache_creation_input_tokens: 60, - }, - api_key_breakdown: { - kA: { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - prompt_tokens: 60, - completion_tokens: 50, - cache_read_input_tokens: 6, - cache_creation_input_tokens: 4, - }, - metadata: { - team_id: "team-1", - key_alias: "alice-key", - user_id: "u1", - user_email: "a@x", - }, - }, - kB: { - metrics: { - spend: 2.2, - api_requests: 22, - successful_requests: 20, - failed_requests: 2, - total_tokens: 220, - prompt_tokens: 130, - completion_tokens: 90, - cache_read_input_tokens: 12, - cache_creation_input_tokens: 8, - }, - metadata: { - team_id: "team-1", - user_id: "u1", - user_email: "a@x", - }, - }, - kC: { - metrics: { - spend: 3.3, - api_requests: 33, - successful_requests: 31, - failed_requests: 2, - total_tokens: 330, - prompt_tokens: 190, - completion_tokens: 140, - cache_read_input_tokens: 18, - cache_creation_input_tokens: 12, - }, - metadata: { - team_id: "team-1", - user_id: "u2", - user_email: null, - }, - }, - kD: { - metrics: { - spend: 4.4, - api_requests: 44, - successful_requests: 42, - failed_requests: 2, - total_tokens: 440, - prompt_tokens: 250, - completion_tokens: 190, - cache_read_input_tokens: 24, - cache_creation_input_tokens: 16, - }, - metadata: { - team_id: "team-1", - user_id: null, - }, - }, - kE: { - metrics: { - spend: 5.5, - api_requests: 55, - successful_requests: 53, - failed_requests: 2, - total_tokens: 550, - prompt_tokens: 310, - completion_tokens: 240, - cache_read_input_tokens: 30, - cache_creation_input_tokens: 20, - }, - metadata: { - team_id: "team-1", - user_id: "u3", - key_exists: false, - }, - }, - }, - }, - "team-2": { - metrics: { - spend: 6.6, - api_requests: 66, - successful_requests: 64, - failed_requests: 2, - total_tokens: 660, - prompt_tokens: 370, - completion_tokens: 290, - cache_read_input_tokens: 36, - cache_creation_input_tokens: 24, - }, - api_key_breakdown: { - kF: { - metrics: { - spend: 6.6, - api_requests: 66, - successful_requests: 64, - failed_requests: 2, - total_tokens: 660, - prompt_tokens: 370, - completion_tokens: 290, - cache_read_input_tokens: 36, - cache_creation_input_tokens: 24, - }, - metadata: { - team_id: "team-2", - user_id: "u1", - user_email: "a@x", - }, - }, - }, - }, - }, - }, - }, - { - date: "2025-03-02", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 7.7, - api_requests: 77, - successful_requests: 75, - failed_requests: 2, - total_tokens: 770, - prompt_tokens: 430, - completion_tokens: 340, - cache_read_input_tokens: 42, - cache_creation_input_tokens: 28, - }, - api_key_breakdown: { - kA: { - metrics: { - spend: 7.7, - api_requests: 77, - successful_requests: 75, - failed_requests: 2, - total_tokens: 770, - prompt_tokens: 430, - completion_tokens: 340, - cache_read_input_tokens: 42, - cache_creation_input_tokens: 28, - }, - metadata: { - team_id: "team-1", - key_alias: "alice-key", - user_id: "u1", - user_email: "a@x", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 30.8, - total_api_requests: 308, - total_successful_requests: 295, - total_failed_requests: 13, - total_tokens: 3080, - }, - }; - - beforeEach(() => { - vi.clearAllMocks(); + expect(name).toBe("team_usage_daily_with_keys_2025-01-05_2025-01-31.csv"); }); + it("falls back to 'all' when a bound is missing", () => { + expect(exportFilename("user", "daily_with_users", "json", { from: undefined, to: undefined })).toBe( + "user_usage_daily_with_users_all_all.json", + ); + }); +}); + +describe("downloadBlob", () => { afterEach(() => { + vi.unstubAllGlobals(); vi.restoreAllMocks(); }); - describe("getEntityBreakdown", () => { - it("should aggregate entity spend data across multiple days", () => { - const result = getEntityBreakdown(mockSpendData); - - expect(result).toHaveLength(2); - expect(result[0].metadata.id).toBe("team-1"); - expect(result[0].metrics.spend).toBe(25.7); - expect(result[1].metadata.id).toBe("team-2"); - expect(result[1].metrics.spend).toBe(20.3); - }); - - it("should sort entities by spend descending", () => { - const result = getEntityBreakdown(mockSpendData); - - expect(result[0].metrics.spend).toBeGreaterThan(result[1].metrics.spend); - }); - - it("should aggregate all metrics correctly", () => { - const result = getEntityBreakdown(mockSpendData); - const entity1 = result.find((e) => e.metadata.id === "team-1"); - - expect(entity1?.metrics.api_requests).toBe(250); - expect(entity1?.metrics.successful_requests).toBe(240); - expect(entity1?.metrics.failed_requests).toBe(10); - expect(entity1?.metrics.total_tokens).toBe(2500); - expect(entity1?.metrics.prompt_tokens).toBe(1500); - expect(entity1?.metrics.completion_tokens).toBe(1000); - expect(entity1?.metrics.cache_read_input_tokens).toBe(125); - expect(entity1?.metrics.cache_creation_input_tokens).toBe(75); - }); - - it("should use entity key as alias when no team alias map is provided", () => { - // Non-team exports (tags, orgs, customers, …) pass no teamAliasMap. - // For teams, this is also the fallback when a team is missing from the map. - const result = getEntityBreakdown(mockSpendData); - const entity1 = result.find((e) => e.metadata.id === "team-1"); - - expect(entity1?.metadata.alias).toBe("team-1"); - }); - - it("should use team alias map to resolve alias from entity key", () => { - const spendDataWithoutAlias: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = getEntityBreakdown(spendDataWithoutAlias, mockTeamAliasMap); - const entity1 = result.find((e) => e.metadata.id === "team-1"); - - expect(entity1?.metadata.alias).toBe("Team One"); - }); - - it("should use entity id when team alias is not available", () => { - const spendDataWithoutTeamId: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: {}, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = getEntityBreakdown(spendDataWithoutTeamId); - const entity1 = result.find((e) => e.metadata.id === "entity1"); - - expect(entity1?.metadata.alias).toBe("entity1"); - }); - - it("should handle empty spend data", () => { - const emptySpendData: EntitySpendData = { - results: [], - metadata: { - total_spend: 0, - total_api_requests: 0, - total_successful_requests: 0, - total_failed_requests: 0, - total_tokens: 0, - }, - }; - - const result = getEntityBreakdown(emptySpendData); - - expect(result).toHaveLength(0); - }); - - it("should handle missing optional token fields", () => { - const spendDataWithMissingTokens: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = getEntityBreakdown(spendDataWithMissingTokens); - const entity1 = result.find((e) => e.metadata.id === "team-1"); - - expect(entity1?.metrics.prompt_tokens).toBe(0); - expect(entity1?.metrics.completion_tokens).toBe(0); - }); - }); - - describe("generateDailyData", () => { - it("should generate daily breakdown data with correct structure", () => { - const result = generateDailyData(mockSpendData, "Team", mockTeamAliasMap); - - expect(result).toHaveLength(3); - expect(result[0]).toHaveProperty("Date"); - expect(result[0]).toHaveProperty("Team"); - expect(result[0]).toHaveProperty("Team ID"); - expect(result[0]).toHaveProperty("Spend ($)"); - expect(result[0]).toHaveProperty("Requests"); - expect(result[0]).toHaveProperty("Successful Requests"); - expect(result[0]).toHaveProperty("Failed Requests"); - expect(result[0]).toHaveProperty("Total Tokens"); - expect(result[0]).toHaveProperty("Prompt Tokens"); - expect(result[0]).toHaveProperty("Completion Tokens"); - expect(result[0]).toHaveProperty("Cache Read Input Tokens"); - expect(result[0]).toHaveProperty("Cache Creation Input Tokens"); - }); - - it("should export exact cache token values per entity per day", () => { - const result = generateDailyData(mockSpendData, "Team", mockTeamAliasMap); - - const day1Team1 = result.find((r) => r.Date === "2025-01-01" && r["Team ID"] === "team-1"); - const day1Team2 = result.find((r) => r.Date === "2025-01-01" && r["Team ID"] === "team-2"); - const day2Team1 = result.find((r) => r.Date === "2025-01-02" && r["Team ID"] === "team-1"); - - expect(day1Team1?.["Cache Read Input Tokens"]).toBe(50); - expect(day1Team1?.["Cache Creation Input Tokens"]).toBe(30); - expect(day1Team2?.["Cache Read Input Tokens"]).toBe(100); - expect(day1Team2?.["Cache Creation Input Tokens"]).toBe(60); - expect(day2Team1?.["Cache Read Input Tokens"]).toBe(75); - expect(day2Team1?.["Cache Creation Input Tokens"]).toBe(45); - }); - - it("should sort data by date ascending", () => { - const result = generateDailyData(mockSpendData, "Team"); - - const dates = result.map((r) => new Date(r.Date).getTime()); - for (let i = 0; i < dates.length - 1; i++) { - expect(dates[i]).toBeLessThanOrEqual(dates[i + 1]); - } - }); - - it("should use team alias when available", () => { - const result = generateDailyData(mockSpendData, "Team", mockTeamAliasMap); - const team1Entry = result.find((r) => r["Team ID"] === "team-1"); - - expect(team1Entry?.["Team"]).toBe("Team One"); - }); - - it("should use dash when team alias is not available", () => { - const result = generateDailyData(mockSpendData, "Team"); - const entryWithoutTeamId = result.find((r) => !r["Team ID"] || r["Team ID"] === "-"); - - if (entryWithoutTeamId) { - expect(entryWithoutTeamId["Team"]).toBe("-"); - } - }); - - it("should fall back to the entity key when there is no team alias mapping", () => { - // e.g. tag/org/customer exports where teamAliasMap has no entry for the entity, - // or a team that isn't in the alias map — the entity key itself is the label. - const spendDataWithoutAlias: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "my-tag": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: {}, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateDailyData(spendDataWithoutAlias, "Tag"); - const entry = result[0]; - - expect(entry["Tag ID"]).toBe("my-tag"); - expect(entry["Tag"]).toBe("my-tag"); - }); - - it("should format spend values correctly", () => { - const result = generateDailyData(mockSpendData, "Team"); - - expect(result[0]["Spend ($)"]).toBeDefined(); - }); - - it("should handle missing optional token fields", () => { - const spendDataWithMissingTokens: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateDailyData(spendDataWithMissingTokens, "Team"); - - expect(result[0]["Prompt Tokens"]).toBe(0); - expect(result[0]["Completion Tokens"]).toBe(0); - expect(result[0]["Cache Read Input Tokens"]).toBe(0); - expect(result[0]["Cache Creation Input Tokens"]).toBe(0); - }); - }); - - describe("generateDailyWithKeysData", () => { - const mockSpendDataWithKeys: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - key2: { - metrics: { - spend: 5.5, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-2", - }, - }, - }, - }, - "team-2": { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 190, - failed_requests: 10, - total_tokens: 2000, - prompt_tokens: 1200, - completion_tokens: 800, - }, - api_key_breakdown: { - key3: { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 190, - failed_requests: 10, - total_tokens: 2000, - prompt_tokens: 1200, - completion_tokens: 800, - }, - metadata: { - team_id: "team-2", - key_alias: "alias-3", - }, - }, - }, - }, - }, - }, - }, - { - date: "2025-01-02", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 15.2, - api_requests: 150, - successful_requests: 145, - failed_requests: 5, - total_tokens: 1500, - prompt_tokens: 900, - completion_tokens: 600, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 15.2, - api_requests: 150, - successful_requests: 145, - failed_requests: 5, - total_tokens: 1500, - prompt_tokens: 900, - completion_tokens: 600, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 46.0, - total_api_requests: 450, - total_successful_requests: 430, - total_failed_requests: 20, - total_tokens: 4500, - }, - }; - - it("should generate daily breakdown with key data and correct structure", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Date"); - expect(result[0]).toHaveProperty("Team"); - expect(result[0]).toHaveProperty("Team ID"); - expect(result[0]).toHaveProperty("Key Alias"); - expect(result[0]).toHaveProperty("Key ID"); - expect(result[0]).toHaveProperty("Spend ($)"); - expect(result[0]).toHaveProperty("Requests"); - expect(result[0]).toHaveProperty("Successful Requests"); - expect(result[0]).toHaveProperty("Failed Requests"); - expect(result[0]).toHaveProperty("Total Tokens"); - expect(result[0]).toHaveProperty("Prompt Tokens"); - expect(result[0]).toHaveProperty("Completion Tokens"); - expect(result[0]).toHaveProperty("Cache Read Input Tokens"); - expect(result[0]).toHaveProperty("Cache Creation Input Tokens"); - }); - - it("should export and aggregate cache token values per key", () => { - const makeDay = (cacheRead: number, cacheCreation: number) => ({ - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 50, - failed_requests: 0, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - cache_read_input_tokens: cacheRead, - cache_creation_input_tokens: cacheCreation, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 50, - failed_requests: 0, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - cache_read_input_tokens: cacheRead, - cache_creation_input_tokens: cacheCreation, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }); - - const spendDataWithCache: EntitySpendData = { - results: [makeDay(40, 25), makeDay(10, 5)], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithCache, "Team"); - - expect(result).toHaveLength(1); - expect(result[0]["Cache Read Input Tokens"]).toBe(50); - expect(result[0]["Cache Creation Input Tokens"]).toBe(30); - }); - - it("should sort data by date ascending", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - - const dates = result.map((r) => new Date(r.Date).getTime()); - for (let i = 0; i < dates.length - 1; i++) { - expect(dates[i]).toBeLessThanOrEqual(dates[i + 1]); - } - }); - - it("should aggregate metrics for duplicate date-team-key combinations", () => { - const spendDataWithDuplicates: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 21.0, - total_api_requests: 200, - total_successful_requests: 190, - total_failed_requests: 10, - total_tokens: 2000, - }, - }; - - const result = generateDailyWithKeysData(spendDataWithDuplicates, "Team"); - const key1Entries = result.filter((r) => r["Key ID"] === "key1"); - - expect(key1Entries).toHaveLength(1); - expect(key1Entries[0].Requests).toBe(100); - expect(key1Entries[0]["Successful Requests"]).toBe(95); - expect(key1Entries[0]["Failed Requests"]).toBe(5); - expect(key1Entries[0]["Total Tokens"]).toBe(1000); - }); - - it("should use team alias when available", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team", mockTeamAliasMap); - const team1Entry = result.find((r) => r["Team ID"] === "team-1"); - - expect(team1Entry?.["Team"]).toBe("Team One"); - }); - - it("should use dash when team alias is not available", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - const entryWithoutTeamAlias = result.find((r) => r["Team ID"] === "team-1" && !mockTeamAliasMap[r["Team ID"]]); - - if (entryWithoutTeamAlias) { - expect(entryWithoutTeamAlias["Team"]).toBe("-"); - } - }); - - it("should use key alias when available", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - const key1Entry = result.find((r) => r["Key ID"] === "key1"); - - expect(key1Entry?.["Key Alias"]).toBe("alias-1"); - }); - - it("should use dash when key alias is not available", () => { - const spendDataWithoutKeyAlias: EntitySpendData = { - ...mockSpendDataWithKeys, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithoutKeyAlias, "Team"); - const key1Entry = result.find((r) => r["Key ID"] === "key1"); - - expect(key1Entry?.["Key Alias"]).toBe("-"); - }); - - it("should use entity id when team id is not available in metadata", () => { - const spendDataWithoutTeamId: EntitySpendData = { - ...mockSpendDataWithKeys, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - metadata: {}, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithoutTeamId, "Team"); - const entry = result.find((r) => r["Key ID"] === "key1"); - - expect(entry?.["Team ID"]).toBe("entity1"); - }); - - it("should use dash when team id is not available", () => { - const spendDataWithoutTeamId: EntitySpendData = { - ...mockSpendDataWithKeys, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - metadata: { - team_id: null, - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithoutTeamId, "Team"); - const entry = result.find((r) => r["Key ID"] === "key1"); - - expect(entry?.["Team ID"]).toBe("entity1"); - }); - - it("should format spend values correctly", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - - expect(result[0]["Spend ($)"]).toBeDefined(); - }); - - it("should handle missing optional token fields", () => { - const spendDataWithMissingTokens: EntitySpendData = { - ...mockSpendDataWithKeys, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithMissingTokens, "Team"); - const key1Entry = result.find((r) => r["Key ID"] === "key1"); - - expect(key1Entry?.["Prompt Tokens"]).toBe(0); - expect(key1Entry?.["Completion Tokens"]).toBe(0); - expect(key1Entry?.["Cache Read Input Tokens"]).toBe(0); - expect(key1Entry?.["Cache Creation Input Tokens"]).toBe(0); - }); - - it("should handle empty api_key_breakdown", () => { - const spendDataWithEmptyBreakdown: EntitySpendData = { - ...mockSpendDataWithKeys, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: {}, - }, - }, - }, - }, - ], - metadata: mockSpendDataWithKeys.metadata, - }; - - const result = generateDailyWithKeysData(spendDataWithEmptyBreakdown, "Team"); - - expect(result).toHaveLength(0); - }); - - it("should handle multiple keys for same team on same date", () => { - const result = generateDailyWithKeysData(mockSpendDataWithKeys, "Team"); - const team1Entries = result.filter((r) => r["Team ID"] === "team-1" && r.Date === "2025-01-01"); - - expect(team1Entries.length).toBeGreaterThanOrEqual(2); - const keyIds = team1Entries.map((r) => r["Key ID"]); - expect(keyIds).toContain("key1"); - expect(keyIds).toContain("key2"); - }); - - it("should emit key owner columns right after Key ID", () => { - const result = generateDailyWithKeysData(usersFixture, "Team"); - - const columnNames = Object.keys(result[0]); - expect(columnNames[columnNames.indexOf("Key ID") + 1]).toBe("User ID"); - expect(columnNames[columnNames.indexOf("User ID") + 1]).toBe("User Email"); - - const kARow = result.find((r) => r["Key ID"] === "kA" && r.Date === "2025-03-01"); - expect(kARow?.["User ID"]).toBe("u1"); - expect(kARow?.["User Email"]).toBe("a@x"); - expect(kARow?.["Key Alias"]).toBe("alice-key"); - - const kCRow = result.find((r) => r["Key ID"] === "kC"); - expect(kCRow?.["User ID"]).toBe("u2"); - expect(kCRow?.["User Email"]).toBe("-"); - - const kDRow = result.find((r) => r["Key ID"] === "kD"); - expect(kDRow?.["User ID"]).toBe("-"); - expect(kDRow?.["User Email"]).toBe("-"); - }); - }); - - describe("generateDailyWithModelsData", () => { - const mockSpendDataWithModels: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - metadata: { - team_id: "team-1", - }, - }, - key2: { - metrics: { - spend: 5.5, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - models: { - "gpt-4": { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - "gpt-3.5-turbo": { - metrics: { - spend: 5.5, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - }, - api_key_breakdown: { - key2: { - metrics: { - spend: 5.5, - api_requests: 50, - successful_requests: 47, - failed_requests: 3, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 10.5, - total_api_requests: 100, - total_successful_requests: 95, - total_failed_requests: 5, - total_tokens: 1000, - }, - }; - - it("should generate daily breakdown with model data", () => { - const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Date"); - expect(result[0]).toHaveProperty("Team"); - expect(result[0]).toHaveProperty("Team ID"); - expect(result[0]).toHaveProperty("Model"); - expect(result[0]).toHaveProperty("Spend ($)"); - expect(result[0]).toHaveProperty("Requests"); - expect(result[0]).toHaveProperty("Successful"); - expect(result[0]).toHaveProperty("Failed"); - expect(result[0]).toHaveProperty("Total Tokens"); - expect(result[0]).toHaveProperty("Prompt Tokens"); - expect(result[0]).toHaveProperty("Completion Tokens"); - expect(result[0]).toHaveProperty("Cache Read Input Tokens"); - expect(result[0]).toHaveProperty("Cache Creation Input Tokens"); - }); - - it("should export prompt, completion, and cache token values summed across keys for the same model", () => { - const data: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 5.0, - api_requests: 25, - successful_requests: 25, - failed_requests: 0, - total_tokens: 1050, - prompt_tokens: 750, - completion_tokens: 300, - cache_read_input_tokens: 450, - cache_creation_input_tokens: 200, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 2.0, - api_requests: 10, - successful_requests: 10, - failed_requests: 0, - total_tokens: 700, - }, - metadata: { team_id: "team-1" }, - }, - key2: { - metrics: { - spend: 3.0, - api_requests: 15, - successful_requests: 15, - failed_requests: 0, - total_tokens: 350, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - models: { - "claude-sonnet-4-5": { - metrics: { - spend: 5.0, - api_requests: 25, - successful_requests: 25, - failed_requests: 0, - total_tokens: 1050, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 2.0, - api_requests: 10, - successful_requests: 10, - failed_requests: 0, - total_tokens: 700, - prompt_tokens: 500, - completion_tokens: 200, - cache_read_input_tokens: 300, - cache_creation_input_tokens: 120, - }, - metadata: { team_id: "team-1" }, - }, - key2: { - metrics: { - spend: 3.0, - api_requests: 15, - successful_requests: 15, - failed_requests: 0, - total_tokens: 350, - prompt_tokens: 250, - completion_tokens: 100, - cache_read_input_tokens: 150, - cache_creation_input_tokens: 80, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 5.0, - total_api_requests: 25, - total_successful_requests: 25, - total_failed_requests: 0, - total_tokens: 1050, - }, - }; - - const result = generateDailyWithModelsData(data, "Team"); - - expect(result).toHaveLength(1); - expect(result[0].Model).toBe("claude-sonnet-4-5"); - expect(result[0]["Total Tokens"]).toBe(1050); - expect(result[0]["Prompt Tokens"]).toBe(750); - expect(result[0]["Completion Tokens"]).toBe(300); - expect(result[0]["Cache Read Input Tokens"]).toBe(450); - expect(result[0]["Cache Creation Input Tokens"]).toBe(200); - }); - - it("should sort data by date ascending", () => { - const multiDayData: EntitySpendData = { - results: [ - { - date: "2025-01-02", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - models: { - "gpt-4": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ...mockSpendDataWithModels.results, - ], - metadata: mockSpendDataWithModels.metadata, - }; - - const result = generateDailyWithModelsData(multiDayData, "Team"); - - expect(new Date(result[0].Date).getTime()).toBeLessThanOrEqual( - new Date(result[result.length - 1].Date).getTime(), - ); - }); - - it("should attribute each model only its own per-key spend", () => { - const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team"); - - const gpt4Entry = result.find((r) => r.Model === "gpt-4"); - const gpt35Entry = result.find((r) => r.Model === "gpt-3.5-turbo"); - - expect(gpt4Entry?.["Spend ($)"]).toBe("5.0000"); - expect(gpt4Entry?.Requests).toBe(50); - expect(gpt4Entry?.["Total Tokens"]).toBe(500); - - expect(gpt35Entry?.["Spend ($)"]).toBe("5.5000"); - expect(gpt35Entry?.Requests).toBe(50); - expect(gpt35Entry?.["Total Tokens"]).toBe(500); - }); - - it("should not duplicate a user's spend across every model (regression for LIT overcount)", () => { - // One user, one key, that key used two models. The entity-level api_key_breakdown - // carries the key's total (8.0) across both models; each model's api_key_breakdown - // carries only that model's share (3.0 + 5.0). The per-model rows must sum back to - // the user-day total, not repeat the total once per model. - const data: EntitySpendData = { - results: [ - { - date: "2025-02-14", - breakdown: { - entities: { - user1: { - metrics: { - spend: 8.0, - api_requests: 80, - successful_requests: 78, - failed_requests: 2, - total_tokens: 800, - prompt_tokens: 500, - completion_tokens: 300, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 8.0, - api_requests: 80, - successful_requests: 78, - failed_requests: 2, - total_tokens: 800, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - models: { - "claude-3-haiku": { - metrics: { - spend: 3.0, - api_requests: 30, - successful_requests: 29, - failed_requests: 1, - total_tokens: 300, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 3.0, - api_requests: 30, - successful_requests: 29, - failed_requests: 1, - total_tokens: 300, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - "claude-sonnet-4-5": { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 49, - failed_requests: 1, - total_tokens: 500, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 49, - failed_requests: 1, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 8.0, - total_api_requests: 80, - total_successful_requests: 78, - total_failed_requests: 2, - total_tokens: 800, - }, - }; - - const result = generateDailyWithModelsData(data, "User"); - - expect(result).toHaveLength(2); - - const haiku = result.find((r) => r.Model === "claude-3-haiku"); - const sonnet = result.find((r) => r.Model === "claude-sonnet-4-5"); - - expect(haiku?.["Spend ($)"]).toBe("3.0000"); - expect(sonnet?.["Spend ($)"]).toBe("5.0000"); - - const totalSpend = result.reduce((sum, r) => sum + parseFloat(r["Spend ($)"].replace(/,/g, "")), 0); - const totalRequests = result.reduce((sum, r) => sum + r.Requests, 0); - const totalTokens = result.reduce((sum, r) => sum + r["Total Tokens"], 0); - - expect(totalSpend).toBeCloseTo(8.0, 4); - expect(totalRequests).toBe(80); - expect(totalTokens).toBe(800); - }); - - it("should omit models the user never called instead of fanning out", () => { - // A second key (key2) belongs to a different user and is the only caller of - // gpt-3.5-turbo. user1 only used key1 -> gpt-4. user1 must get exactly one row. - const data: EntitySpendData = { - results: [ - { - date: "2025-02-14", - breakdown: { - entities: { - user1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - models: { - "gpt-4": { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 5.0, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - "gpt-3.5-turbo": { - metrics: { - spend: 9.0, - api_requests: 90, - successful_requests: 90, - failed_requests: 0, - total_tokens: 900, - }, - api_key_breakdown: { - key2: { - metrics: { - spend: 9.0, - api_requests: 90, - successful_requests: 90, - failed_requests: 0, - total_tokens: 900, - }, - metadata: { team_id: "team-2" }, - }, - }, - }, - }, - }, - }, - ], - metadata: { - total_spend: 14.0, - total_api_requests: 140, - total_successful_requests: 138, - total_failed_requests: 2, - total_tokens: 1400, - }, - }; - - const result = generateDailyWithModelsData(data, "User"); - - expect(result).toHaveLength(1); - expect(result[0].Model).toBe("gpt-4"); - expect(result[0]["Spend ($)"]).toBe("5.0000"); - }); - - it("should use team alias when available", () => { - const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team", mockTeamAliasMap); - const team1Entry = result.find((r) => r["Team ID"] === "team-1"); - - expect(team1Entry?.["Team"]).toBe("Team One"); - }); - - it("should use dash when team alias is not available", () => { - const result = generateDailyWithModelsData(mockSpendDataWithModels, "Team"); - const entryWithoutTeamId = result.find((r) => !r["Team ID"] || r["Team ID"] === "-"); - - if (entryWithoutTeamId) { - expect(entryWithoutTeamId["Team"]).toBe("-"); - } - }); - - it("should handle empty models breakdown", () => { - const spendDataWithoutModels: EntitySpendData = { - ...mockSpendDataWithModels, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - models: {}, - }, - }, - ], - metadata: mockSpendDataWithModels.metadata, - }; - - const result = generateDailyWithModelsData(spendDataWithoutModels, "Team"); - - expect(result).toHaveLength(0); - }); - }); - - describe("generateExportData", () => { - it("should return daily data when scope is daily", () => { - const result = generateExportData(mockSpendData, "daily", "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Date"); - expect(result[0]).not.toHaveProperty("Model"); - }); - - it("should return daily with keys data when scope is daily_with_keys", () => { - const mockDataWithKeys: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - }, - metadata: { - team_id: "team-1", - key_alias: "alias-1", - }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateExportData(mockDataWithKeys, "daily_with_keys", "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Key Alias"); - expect(result[0]).toHaveProperty("Key ID"); - expect(result[0]).not.toHaveProperty("Model"); - }); - - it("should return daily with models data when scope is daily_with_models", () => { - const mockDataWithModels: EntitySpendData = { - ...mockSpendData, - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - entity1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - prompt_tokens: 600, - completion_tokens: 400, - cache_read_input_tokens: 50, - cache_creation_input_tokens: 30, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { - team_id: "team-1", - }, - }, - }, - }, - }, - models: { - "gpt-4": { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { team_id: "team-1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateExportData(mockDataWithModels, "daily_with_models", "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Model"); - }); - - it("should default to daily data for unknown scope", () => { - const result = generateExportData(mockSpendData, "unknown" as ExportScope, "Team", mockTeamAliasMap); - - expect(result.length).toBeGreaterThan(0); - expect(result[0]).not.toHaveProperty("Model"); - }); - }); - - describe("generateMetadata", () => { - const mockDateRange: DateRangePickerValue = { - from: new Date("2025-01-01"), - to: new Date("2025-01-31"), - }; - - it("should generate metadata with correct structure", () => { - const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); - - expect(result).toHaveProperty("export_date"); - expect(result).toHaveProperty("entity_type"); - expect(result).toHaveProperty("date_range"); - expect(result).toHaveProperty("filters_applied"); - expect(result).toHaveProperty("export_scope"); - expect(result).toHaveProperty("summary"); - }); - - it("should include export date as ISO string", () => { - const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); - - expect(result.export_date).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); - }); - - it("should include entity type", () => { - const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); - - expect(result.entity_type).toBe("team"); - }); - - it("should format date range correctly", () => { - const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); - - expect(result.date_range.from).toBe("2025-01-01T00:00:00.000Z"); - expect(result.date_range.to).toBe("2025-01-31T00:00:00.000Z"); - }); - - it("should handle missing date range values", () => { - const incompleteDateRange: DateRangePickerValue = { - from: undefined, - to: undefined, - }; - - const result = generateMetadata("team", incompleteDateRange, [], "daily", mockSpendData); - - expect(result.date_range.from).toBeUndefined(); - expect(result.date_range.to).toBeUndefined(); - }); - - it("should set filters_applied to None when empty", () => { - const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); - - expect(result.filters_applied).toBe("None"); - }); - - it("should include filters when provided", () => { - const result = generateMetadata("team", mockDateRange, ["filter1", "filter2"], "daily", mockSpendData); - - expect(result.filters_applied).toEqual(["filter1", "filter2"]); - }); - - it("should include export scope", () => { - const result = generateMetadata("team", mockDateRange, [], "daily_with_models", mockSpendData); - - expect(result.export_scope).toBe("daily_with_models"); - }); - - it("should include summary metrics from spend data", () => { - const result = generateMetadata("team", mockDateRange, [], "daily", mockSpendData); - - expect(result.summary.total_spend).toBe(46.0); - expect(result.summary.total_requests).toBe(450); - expect(result.summary.successful_requests).toBe(430); - expect(result.summary.failed_requests).toBe(20); - expect(result.summary.total_tokens).toBe(4500); - }); - - it("should include total_flat_cost and total_cost in summary when total_flat_cost is present", () => { - const spendWithFlat: EntitySpendData = { - ...mockSpendData, - metadata: { ...mockSpendData.metadata, total_flat_cost: 6.45 }, - }; - const result = generateMetadata("team", mockDateRange, [], "daily", spendWithFlat); - expect(result.summary.total_flat_cost).toBeCloseTo(6.45, 4); - expect(result.summary.total_cost).toBeCloseTo(46.0 + 6.45, 4); - }); - - it("should omit total_flat_cost and total_cost when total_flat_cost is zero", () => { - const zeroFlat = { ...mockSpendData, metadata: { ...mockSpendData.metadata, total_flat_cost: 0 } }; - const result = generateMetadata("team", mockDateRange, [], "daily", zeroFlat); - expect(result.summary.total_flat_cost).toBeUndefined(); - expect(result.summary.total_cost).toBeUndefined(); - }); - }); - - describe("generateDailyData PTU flat cost", () => { - const dayWithFlat: EntitySpendData = { - results: [ - { - date: "2025-01-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 10, - flat_cost: 6.45, - api_requests: 50, - successful_requests: 50, - failed_requests: 0, - total_tokens: 500, - prompt_tokens: 300, - completion_tokens: 200, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, - }, - api_key_breakdown: {}, - }, - }, - }, - }, - ], - metadata: { - total_spend: 10, - total_flat_cost: 6.45, - total_api_requests: 50, - total_successful_requests: 50, - total_failed_requests: 0, - total_tokens: 500, - }, - }; - - it("includes Flat Cost ($) and Total Cost ($) columns when total_flat_cost is present", () => { - const rows = generateDailyData(dayWithFlat, "Team", {}); - expect(rows).toHaveLength(1); - expect(rows[0]).toHaveProperty("Flat Cost ($)"); - expect(rows[0]).toHaveProperty("Total Cost ($)"); - expect(rows[0]["Flat Cost ($)"]).toBe("6.4500"); - expect(rows[0]["Total Cost ($)"]).toBe("16.4500"); - }); - - it("does not include Flat Cost / Total Cost columns when total_flat_cost is zero", () => { - const spendWithoutFlat: EntitySpendData = { - ...dayWithFlat, - metadata: { - total_spend: 10, - total_api_requests: 50, - total_successful_requests: 50, - total_failed_requests: 0, - total_tokens: 500, - total_flat_cost: 0, - }, - }; - const rows = generateDailyData(spendWithoutFlat, "User", {}); - expect(rows).toHaveLength(1); - expect(rows[0]).not.toHaveProperty("Flat Cost ($)"); - expect(rows[0]).not.toHaveProperty("Total Cost ($)"); - }); - }); - - describe("handleExportCSV", () => { - beforeEach(() => { - document.body.innerHTML = ""; - window.URL.createObjectURL = vi.fn(() => "blob:mock-url"); - window.URL.revokeObjectURL = vi.fn(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("should create CSV file and trigger download", () => { - const createObjectURLSpy = vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:mock-url"); - vi.spyOn(window.URL, "revokeObjectURL"); - const createElementSpy = vi.spyOn(document, "createElement"); - const appendChildSpy = vi.spyOn(document.body, "appendChild"); - const removeChildSpy = vi.spyOn(document.body, "removeChild"); - - handleExportCSV(mockSpendData, "daily", "Team", "team", mockTeamAliasMap); - - const unparsedRows = vi.mocked(Papa.unparse).mock.calls[0][0] as Record[]; - expect(unparsedRows).toHaveLength(3); - const day1Team1 = unparsedRows.find((r) => r["Date"] === "2025-01-01" && r["Team ID"] === "team-1"); - expect(day1Team1?.["Cache Read Input Tokens"]).toBe(50); - - const exportedBlob = createObjectURLSpy.mock.calls[0][0] as Blob; - expect(exportedBlob.type).toBe("text/csv;charset=utf-8;"); - - expect(createElementSpy).toHaveBeenCalledWith("a"); - const attached = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement; - expect(attached.download).toMatch(/^team_usage_daily_.*\.csv$/); - expect(removeChildSpy).toHaveBeenCalledWith(attached); - }); - - it("should generate correct filename", () => { - const anchorElement = document.createElement("a"); - vi.spyOn(document, "createElement").mockReturnValue(anchorElement); - - const today = new Date().toISOString().split("T")[0]; - - handleExportCSV(mockSpendData, "daily", "Team", "team", mockTeamAliasMap); - - expect(anchorElement.download).toBe(`team_usage_daily_${today}.csv`); - }); - - it("should create blob with correct type", () => { - let blobType = ""; - const originalBlob = window.Blob; - - window.Blob = class extends Blob { - constructor(parts?: BlobPart[] | undefined, options?: BlobPropertyBag | undefined) { - super(parts, options); - if (options?.type) { - blobType = options.type; - } - } - } as any; - - handleExportCSV(mockSpendData, "daily", "Team", "team", mockTeamAliasMap); - - expect(blobType).toBe("text/csv;charset=utf-8;"); - - window.Blob = originalBlob; - }); - - it("should generate the daily_with_users filename and include User ID in the rows", () => { - const anchorElement = document.createElement("a"); - vi.spyOn(document, "createElement").mockReturnValue(anchorElement); - vi.useFakeTimers(); - vi.setSystemTime(new Date("2025-03-01T12:00:00Z")); - - handleExportCSV(usersFixture, "daily_with_users", "Team", "team", mockTeamAliasMap); - vi.useRealTimers(); - - expect(anchorElement.download).toBe("team_usage_daily_with_users_2025-03-01.csv"); - - const unparsedRows = vi.mocked(Papa.unparse).mock.calls[0][0] as Record[]; - expect(unparsedRows[0]).toHaveProperty("User ID"); - }); - }); - - describe("handleExportJSON", () => { - beforeEach(() => { - document.body.innerHTML = ""; - window.URL.createObjectURL = vi.fn(() => "blob:mock-url"); - window.URL.revokeObjectURL = vi.fn(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("should create JSON file and trigger download", () => { - const createObjectURLSpy = vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:mock-url"); - vi.spyOn(window.URL, "revokeObjectURL"); - const createElementSpy = vi.spyOn(document, "createElement"); - const appendChildSpy = vi.spyOn(document.body, "appendChild"); - const removeChildSpy = vi.spyOn(document.body, "removeChild"); - - const mockDateRange: DateRangePickerValue = { - from: new Date("2025-01-01"), - to: new Date("2025-01-31"), - }; - - handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, [], mockTeamAliasMap); - - const exportedBlob = createObjectURLSpy.mock.calls[0][0] as Blob; - expect(exportedBlob.type).toBe("application/json"); - - expect(createElementSpy).toHaveBeenCalledWith("a"); - const attached = appendChildSpy.mock.calls[0][0] as HTMLAnchorElement; - expect(attached.download).toMatch(/^team_usage_daily_.*\.json$/); - expect(removeChildSpy).toHaveBeenCalledWith(attached); - }); - - it("should generate correct filename", () => { - const anchorElement = document.createElement("a"); - vi.spyOn(document, "createElement").mockReturnValue(anchorElement); - - const today = new Date().toISOString().split("T")[0]; - const mockDateRange: DateRangePickerValue = { - from: new Date("2025-01-01"), - to: new Date("2025-01-31"), - }; - - handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, [], mockTeamAliasMap); - - expect(anchorElement.download).toBe(`team_usage_daily_${today}.json`); - }); - - it("should create blob with correct type", () => { - let blobType = ""; - const originalBlob = window.Blob; - - window.Blob = class extends Blob { - constructor(parts?: BlobPart[] | undefined, options?: BlobPropertyBag | undefined) { - super(parts, options); - if (options?.type) { - blobType = options.type; - } - } - } as any; - - const mockDateRange: DateRangePickerValue = { - from: new Date("2025-01-01"), - to: new Date("2025-01-31"), - }; - - handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, [], mockTeamAliasMap); - - expect(blobType).toBe("application/json"); - - window.Blob = originalBlob; - }); - - it("should include metadata and data in JSON export", () => { - let jsonString = ""; - const originalBlob = window.Blob; - - window.Blob = class extends Blob { - constructor(parts?: BlobPart[] | undefined, options?: BlobPropertyBag | undefined) { - super(parts, options); - if (parts && parts[0]) { - jsonString = parts[0] as string; - } - } - } as any; - - const mockDateRange: DateRangePickerValue = { - from: new Date("2025-01-01"), - to: new Date("2025-01-31"), - }; - - handleExportJSON(mockSpendData, "daily", "Team", "team", mockDateRange, ["filter1"], mockTeamAliasMap); - - const exportObject = JSON.parse(jsonString); - expect(exportObject).toHaveProperty("metadata"); - expect(exportObject).toHaveProperty("data"); - expect(exportObject.metadata.entity_type).toBe("team"); - expect(exportObject.metadata.filters_applied).toEqual(["filter1"]); - - window.Blob = originalBlob; - }); - }); - - describe("resolveEntities and aggregated endpoint fallback", () => { - // Simulates the response from /user/daily/activity/aggregated which has - // empty entities but populated api_keys at the breakdown level. - // Derived from mockSpendData: flatten all entities' api_key_breakdowns - // into top-level api_keys, clear entities, and add a second key for team-1 - // to test multi-key grouping. - const aggregatedSpendData: EntitySpendData = { - ...mockSpendData, - results: mockSpendData.results.slice(0, 1).map((day) => ({ - ...day, - breakdown: { - entities: {}, - api_keys: { - ...Object.fromEntries( - Object.values(day.breakdown.entities as Record).flatMap((e: any) => - Object.entries(e.api_key_breakdown || {}), - ), - ), - // Extra key on team-1 to test multi-key-per-team aggregation - key1b: { - metrics: { spend: 5, api_requests: 50, successful_requests: 48, failed_requests: 2, total_tokens: 500 }, - metadata: { team_id: "team-1", key_alias: "staging-key" }, - }, - }, - models: { - "gpt-4": { - metrics: { spend: 35.8, api_requests: 350, total_tokens: 3500 }, - api_key_breakdown: { - key1: { - metrics: { - spend: 10.5, - api_requests: 100, - successful_requests: 95, - failed_requests: 5, - total_tokens: 1000, - }, - metadata: { team_id: "team-1" }, - }, - key1b: { - metrics: { - spend: 5, - api_requests: 50, - successful_requests: 48, - failed_requests: 2, - total_tokens: 500, - }, - metadata: { team_id: "team-1" }, - }, - key2: { - metrics: { - spend: 20.3, - api_requests: 200, - successful_requests: 195, - failed_requests: 5, - total_tokens: 2000, - }, - metadata: { team_id: "team-2" }, - }, - }, - }, - }, - }, - })), - }; - - describe("resolveEntities", () => { - it("should return entities when populated", () => { - const breakdown = { - entities: { e1: { metrics: { spend: 1 } } }, - api_keys: { k1: { metrics: { spend: 2 }, metadata: { team_id: "t1" } } }, - }; - const result = resolveEntities(breakdown); - expect(result).toBe(breakdown.entities); - }); - - it("should aggregate api_keys into entities when entities is empty", () => { - const breakdown = aggregatedSpendData.results[0].breakdown; - const result = resolveEntities(breakdown); - - // Two teams: team-1 (key1+key2) and team-2 (key3) - expect(Object.keys(result)).toHaveLength(2); - expect(result["team-1"]).toBeDefined(); - expect(result["team-2"]).toBeDefined(); - - // team-1 spend = 10.5 (key1) + 5 (key1b) - expect(result["team-1"].metrics.spend).toBe(15.5); - expect(result["team-1"].metrics.api_requests).toBe(150); - expect(result["team-1"].metrics.total_tokens).toBe(1500); - - // team-2 spend = 20.3 (key2) - expect(result["team-2"].metrics.spend).toBe(20.3); - expect(result["team-2"].metrics.api_requests).toBe(200); - }); - - it("should use 'Unassigned' for keys without team_id", () => { - const breakdown = { - entities: {}, - api_keys: { - k1: { - metrics: { spend: 7, api_requests: 10, successful_requests: 10, failed_requests: 0, total_tokens: 100 }, - metadata: {}, - }, - }, - }; - const result = resolveEntities(breakdown); - expect(result["Unassigned"]).toBeDefined(); - expect(result["Unassigned"].metrics.spend).toBe(7); - }); - - it("should handle missing or empty api_keys gracefully", () => { - expect(Object.keys(resolveEntities({ entities: {}, api_keys: {} }))).toHaveLength(0); - expect(Object.keys(resolveEntities({ entities: {} }))).toHaveLength(0); - }); - - it("should preserve api_key_breakdown on aggregated entities", () => { - const breakdown = aggregatedSpendData.results[0].breakdown; - const result = resolveEntities(breakdown); - - // team-1 should have key1 and key1b in api_key_breakdown - expect(Object.keys(result["team-1"].api_key_breakdown)).toEqual(["key1", "key1b"]); - // team-2 should have key2 - expect(Object.keys(result["team-2"].api_key_breakdown)).toEqual(["key2"]); - }); - }); - - describe("getEntityBreakdown with aggregated data", () => { - it("should produce breakdown from api_keys when entities is empty", () => { - const result = getEntityBreakdown(aggregatedSpendData); - expect(result.length).toBeGreaterThan(0); - - // Sorted by spend desc: team-2 (20.3) then team-1 (15.5) - expect(result[0].metrics.spend).toBe(20.3); - expect(result[1].metrics.spend).toBe(15.5); - }); - }); - - describe("generateDailyData with aggregated data", () => { - it("should produce rows from api_keys when entities is empty", () => { - const result = generateDailyData(aggregatedSpendData, "Team"); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Date"); - expect(result[0]).toHaveProperty("Team"); - }); - }); - - describe("generateDailyWithKeysData with aggregated data", () => { - it("should produce rows from api_keys when entities is empty", () => { - const result = generateDailyWithKeysData(aggregatedSpendData, "Team"); - expect(result.length).toBeGreaterThan(0); - - // Should have 3 key rows (key1, key1b, key2) - expect(result).toHaveLength(3); - const keyIds = result.map((r) => r["Key ID"]); - expect(keyIds).toContain("key1"); - expect(keyIds).toContain("key1b"); - expect(keyIds).toContain("key2"); - }); - }); - - describe("generateDailyWithModelsData with aggregated data", () => { - it("should produce rows from api_keys when entities is empty", () => { - const result = generateDailyWithModelsData(aggregatedSpendData, "Team"); - expect(result.length).toBeGreaterThan(0); - expect(result[0]).toHaveProperty("Model"); - - // team-1 = key1 (10.5) + key1b (5) on gpt-4; team-2 = key2 (20.3) on gpt-4. - // Spend must aggregate per team-key, not repeat the model total per team. - const team1 = result.find((r) => r["Team ID"] === "team-1"); - const team2 = result.find((r) => r["Team ID"] === "team-2"); - expect(team1?.["Spend ($)"]).toBe("15.5000"); - expect(team2?.["Spend ($)"]).toBe("20.3000"); - }); - }); - }); - - describe("display name resolution from entity metadata", () => { - const entityMetrics = { - spend: 12.25, - api_requests: 40, - successful_requests: 39, - failed_requests: 1, - total_tokens: 900, - prompt_tokens: 500, - completion_tokens: 400, - cache_read_input_tokens: 20, - cache_creation_input_tokens: 10, - }; - - const makeSpendData = (entity: string, metadata?: Record): EntitySpendData => ({ - results: [ - { - date: "2025-04-01", - breakdown: { - entities: { - [entity]: { - metrics: entityMetrics, - metadata, - api_key_breakdown: { - key1: { - metrics: entityMetrics, - metadata: { key_alias: "prod-key" }, - }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }); - - it("should export the user email as the entity label and keep the raw user id in the id column", () => { - const result = generateDailyData( - makeSpendData("user-123", { user_email: "ada@example.com", user_alias: "Ada" }), - "User", - ); - - expect(result).toHaveLength(1); - expect(result[0]["User"]).toBe("ada@example.com"); - expect(result[0]["User ID"]).toBe("user-123"); - }); - - it("should fall back to the user alias when the user has no email", () => { - const nullEmail = generateDailyData( - makeSpendData("user-123", { user_email: null, user_alias: "Ada Lovelace" }), - "User", - ); - const missingEmail = generateDailyData(makeSpendData("user-123", { user_alias: "Ada Lovelace" }), "User"); - - expect(nullEmail[0]["User"]).toBe("Ada Lovelace"); - expect(missingEmail[0]["User"]).toBe("Ada Lovelace"); - }); - - it("should fall back to the raw entity key when the entity carries no metadata", () => { - const noMetadata = generateDailyData(makeSpendData("my-tag"), "Tag"); - const emptyMetadata = generateDailyData(makeSpendData("customer-9", {}), "Customer"); - const blankNames = generateDailyData(makeSpendData("user-123", { user_email: null, user_alias: null }), "User"); - - expect(noMetadata[0]["Tag"]).toBe("my-tag"); - expect(emptyMetadata[0]["Customer"]).toBe("customer-9"); - expect(blankNames[0]["User"]).toBe("user-123"); - }); - - it("should prefer the team alias map over any alias in entity metadata", () => { - const result = generateDailyData( - makeSpendData("team-1", { team_alias: "Stale Alias", user_email: "ada@example.com" }), - "Team", - mockTeamAliasMap, - ); - - expect(result[0]["Team"]).toBe("Team One"); - }); - - it("should use the team alias from entity metadata when the alias map has no entry for the team", () => { - const result = generateDailyData( - makeSpendData("team-9", { team_alias: "Team Nine", user_email: "ada@example.com" }), - "Team", - mockTeamAliasMap, - ); - - expect(result[0]["Team"]).toBe("Team Nine"); - }); - - it("should resolve metadata.alias to the user email in getEntityBreakdown", () => { - const withEmail = getEntityBreakdown( - makeSpendData("user-123", { user_email: "ada@example.com", user_alias: "Ada" }), - ); - const withoutEmail = getEntityBreakdown(makeSpendData("user-123", { user_alias: "Ada" })); - - expect(withEmail[0].metadata.alias).toBe("ada@example.com"); - expect(withEmail[0].metadata.id).toBe("user-123"); - expect(withoutEmail[0].metadata.alias).toBe("Ada"); - }); - - it("should resolve the user email on every key row of the keys scope", () => { - const spendData: EntitySpendData = { - results: [ - { - date: "2025-04-01", - breakdown: { - entities: { - "user-123": { - metrics: entityMetrics, - metadata: { user_email: "ada@example.com", user_alias: "Ada" }, - api_key_breakdown: { - key1: { metrics: entityMetrics, metadata: { key_alias: "prod-key" } }, - key2: { metrics: entityMetrics, metadata: { key_alias: "dev-key" } }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateDailyWithKeysData(spendData, "User"); - - expect(result).toHaveLength(2); - expect(result.map((r) => r["User"])).toEqual(["ada@example.com", "ada@example.com"]); - expect(result.map((r) => r["User ID"])).toEqual(["user-123", "user-123"]); - expect(result.find((r) => r["Key ID"] === "key1")?.["Key Alias"]).toBe("prod-key"); - expect(result.find((r) => r["Key ID"] === "key2")?.["Key Alias"]).toBe("dev-key"); - }); - - it("should resolve each entity's own email in the models scope", () => { - const spendData: EntitySpendData = { - results: [ - { - date: "2025-04-01", - breakdown: { - entities: { - "user-a": { - metrics: entityMetrics, - metadata: { user_email: "ada@example.com", user_alias: "Ada" }, - api_key_breakdown: { key1: { metrics: entityMetrics, metadata: {} } }, - }, - "user-b": { - metrics: entityMetrics, - metadata: { user_email: null, user_alias: "Grace" }, - api_key_breakdown: { key2: { metrics: entityMetrics, metadata: {} } }, - }, - }, - models: { - "claude-sonnet-4-5": { - metrics: entityMetrics, - api_key_breakdown: { - key1: { metrics: entityMetrics, metadata: {} }, - key2: { metrics: entityMetrics, metadata: {} }, - }, - }, - }, - }, - }, - ], - metadata: mockSpendData.metadata, - }; - - const result = generateDailyWithModelsData(spendData, "User"); - - expect(result).toHaveLength(2); - expect(result.every((r) => r.Model === "claude-sonnet-4-5")).toBe(true); - expect(result.find((r) => r["User ID"] === "user-a")?.["User"]).toBe("ada@example.com"); - expect(result.find((r) => r["User ID"] === "user-b")?.["User"]).toBe("Grace"); - }); - }); - - describe("generateDailyWithUsersData", () => { - it("should reconcile spend with daily_with_keys and daily per date and team", () => { - const byUser = generateDailyWithUsersData(usersFixture, "Team"); - const byKey = generateDailyWithKeysData(usersFixture, "Team"); - const daily = generateDailyData(usersFixture, "Team"); - - expect(byUser.length).toBeGreaterThan(0); - expect(byKey.length).toBeGreaterThan(0); - expect(daily.length).toBeGreaterThan(0); - - const sumSpend = (rows: any[]): Record => { - const totals: Record = {}; - rows.forEach((r) => { - const bucket = `${r.Date}|${r["Team ID"]}`; - totals[bucket] = (totals[bucket] || 0) + Number(r["Spend ($)"]); - }); - return totals; - }; - - const userTotals = sumSpend(byUser); - const keyTotals = sumSpend(byKey); - - daily.forEach((row) => { - const bucket = `${row.Date}|${row["Team ID"]}`; - expect(userTotals[bucket]).toBeCloseTo(Number(row["Spend ($)"]), 4); - expect(keyTotals[bucket]).toBeCloseTo(Number(row["Spend ($)"]), 4); - }); - }); - - it("should roll multiple keys owned by one user in a team into a single row", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const matches = rows.filter((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "u1"); - - expect(matches).toHaveLength(1); - const row = matches[0]; - expect(row.Keys).toBe(2); - expect(row["User Email"]).toBe("a@x"); - expect(row["Spend ($)"]).toBe("3.3000"); - expect(row.Requests).toBe(33); - expect(row["Successful Requests"]).toBe(30); - expect(row["Failed Requests"]).toBe(3); - expect(row["Total Tokens"]).toBe(330); - expect(row["Prompt Tokens"]).toBe(190); - expect(row["Completion Tokens"]).toBe(140); - expect(row["Cache Read Input Tokens"]).toBe(18); - expect(row["Cache Creation Input Tokens"]).toBe(12); - }); - - it("should bucket keys with no owner into an Unassigned row without dropping spend", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const row = rows.find( - (r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "Unassigned", - ); - - expect(row).toBeDefined(); - expect(row?.["User Email"]).toBe("-"); - expect(Number(row?.["Spend ($)"])).toBeCloseTo(4.4, 4); - }); - - it("should keep different users in the same team as separate rows", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const teamRows = rows.filter((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1"); - - const u1Rows = teamRows.filter((r) => r["User ID"] === "u1"); - const u2Rows = teamRows.filter((r) => r["User ID"] === "u2"); - expect(u1Rows).toHaveLength(1); - expect(u2Rows).toHaveLength(1); - expect(Number(u2Rows[0]["Spend ($)"])).toBeCloseTo(3.3, 4); - }); - - it("should keep the same user in different teams as separate rows", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const u1Rows = rows.filter((r) => r.Date === "2025-03-01" && r["User ID"] === "u1"); - - expect(u1Rows).toHaveLength(2); - const team1Row = u1Rows.find((r) => r["Team ID"] === "team-1"); - const team2Row = u1Rows.find((r) => r["Team ID"] === "team-2"); - expect(team1Row?.Keys).toBe(2); - expect(team2Row?.Keys).toBe(1); - expect(Number(team2Row?.["Spend ($)"])).toBeCloseTo(6.6, 4); - }); - - it("should show a dash email when the user has none", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const row = rows.find((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "u3"); - - expect(row).toBeDefined(); - expect(row?.["User Email"]).toBe("-"); - }); - - it("should still attribute a deleted key to its user", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - const row = rows.find((r) => r.Date === "2025-03-01" && r["Team ID"] === "team-1" && r["User ID"] === "u3"); - - expect(row).toBeDefined(); - expect(Number(row?.["Spend ($)"])).toBeCloseTo(5.5, 4); - expect(row?.Requests).toBe(55); - }); - - it("should group rows under team_id on the aggregated endpoint shape", () => { - const aggregatedFixture: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: {}, - api_keys: { - kA: { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - prompt_tokens: 60, - completion_tokens: 50, - cache_read_input_tokens: 6, - cache_creation_input_tokens: 4, - }, - metadata: { team_id: "team-1", user_id: "u1", user_email: "a@x" }, - }, - kF: { - metrics: { - spend: 6.6, - api_requests: 66, - successful_requests: 64, - failed_requests: 2, - total_tokens: 660, - prompt_tokens: 370, - completion_tokens: 290, - cache_read_input_tokens: 36, - cache_creation_input_tokens: 24, - }, - metadata: { team_id: "team-2", user_id: "u2" }, - }, - }, - }, - }, - ], - metadata: usersFixture.metadata, - }; - - const rows = generateDailyWithUsersData(aggregatedFixture, "Team"); - - expect(rows).toHaveLength(2); - const team1Row = rows.find((r) => r["Team ID"] === "team-1"); - const team2Row = rows.find((r) => r["Team ID"] === "team-2"); - expect(team1Row?.["User ID"]).toBe("u1"); - expect(team2Row?.["User ID"]).toBe("u2"); - expect(Number(team1Row?.["Spend ($)"])).toBeCloseTo(1.1, 4); - expect(Number(team2Row?.["Spend ($)"])).toBeCloseTo(6.6, 4); - }); - - it("should emit the exact column order and sort by date ascending", () => { - const rows = generateDailyWithUsersData(usersFixture, "Team"); - - expect(Object.keys(rows[0])).toEqual([ - "Date", - "Team", - "Team ID", - "User ID", - "User Email", - "Keys", - "Spend ($)", - "Requests", - "Successful Requests", - "Failed Requests", - "Total Tokens", - "Prompt Tokens", - "Completion Tokens", - "Cache Read Input Tokens", - "Cache Creation Input Tokens", - ]); - - const dates = rows.map((r) => new Date(r.Date).getTime()); - for (let i = 0; i < dates.length - 1; i++) { - expect(dates[i]).toBeLessThanOrEqual(dates[i + 1]); - } - }); - - it("should dispatch daily_with_users through generateExportData", () => { - expect(generateExportData(usersFixture, "daily_with_users", "Team")).toEqual( - generateDailyWithUsersData(usersFixture, "Team"), - ); - }); - - it("should keep owners separate when entity and user ids contain underscores", () => { - const collisionFixture: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: { - team_1: { - metrics: { spend: 1, api_requests: 1, total_tokens: 10 }, - api_key_breakdown: { - kX: { - metrics: { spend: 1, api_requests: 1, total_tokens: 10 }, - metadata: { team_id: "team_1", user_id: "u1" }, - }, - }, - }, - team: { - metrics: { spend: 2, api_requests: 2, total_tokens: 20 }, - api_key_breakdown: { - kY: { - metrics: { spend: 2, api_requests: 2, total_tokens: 20 }, - metadata: { team_id: "team", user_id: "1_u1" }, - }, - }, - }, - }, - }, - }, - ], - metadata: usersFixture.metadata, - }; - - const rows = generateDailyWithUsersData(collisionFixture, "Team"); - - expect(rows).toHaveLength(2); - const team1Row = rows.find((r) => r["Team ID"] === "team_1"); - expect(team1Row?.["User ID"]).toBe("u1"); - expect(team1Row?.Keys).toBe(1); - expect(team1Row?.["Spend ($)"]).toBe("1.0000"); - const teamRow = rows.find((r) => r["Team ID"] === "team"); - expect(teamRow?.["User ID"]).toBe("1_u1"); - expect(teamRow?.Keys).toBe(1); - expect(teamRow?.["Spend ($)"]).toBe("2.0000"); - }); - - it("should leave daily and daily_with_models output without user columns", () => { - const daily = generateDailyData(usersFixture, "Team"); - expect(daily[0]).not.toHaveProperty("User ID"); - - const modelsFixture: EntitySpendData = { - results: [ - { - date: "2025-03-01", - breakdown: { - entities: { - "team-1": { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - prompt_tokens: 60, - completion_tokens: 50, - }, - api_key_breakdown: { - kA: { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - }, - metadata: { team_id: "team-1", user_id: "u1", user_email: "a@x" }, - }, - }, - }, - }, - models: { - "gpt-4o": { - metrics: { spend: 1.1, api_requests: 11, total_tokens: 110 }, - api_key_breakdown: { - kA: { - metrics: { - spend: 1.1, - api_requests: 11, - successful_requests: 10, - failed_requests: 1, - total_tokens: 110, - }, - metadata: {}, - }, - }, - }, - }, - }, - }, - ], - metadata: usersFixture.metadata, - }; - - const modelRows = generateDailyWithModelsData(modelsFixture, "Team"); - expect(modelRows).toHaveLength(1); - expect(Object.keys(modelRows[0])).toEqual([ - "Date", - "Team", - "Team ID", - "Model", - "Spend ($)", - "Requests", - "Successful", - "Failed", - "Total Tokens", - "Prompt Tokens", - "Completion Tokens", - "Cache Read Input Tokens", - "Cache Creation Input Tokens", - ]); - }); + it("triggers an anchor click carrying the filename and revokes the object URL", () => { + const click = vi.fn(); + const revoke = vi.fn(); + const anchor = { href: "", download: "", click, remove: vi.fn() }; + const createElement = vi.fn(() => anchor); + const appendChild = vi.fn((node: unknown) => node); + const removeChild = vi.fn((node: unknown) => node); + const fakeURL = { ...URL, createObjectURL: vi.fn(() => "blob:mock"), revokeObjectURL: revoke }; + vi.stubGlobal("URL", fakeURL); + vi.stubGlobal("window", { URL: fakeURL }); + vi.stubGlobal("document", { + createElement, + body: { appendChild, removeChild }, + }); + + downloadBlob(new Blob(["data"]), "team_usage_daily_2025-01-01_2025-01-31.csv"); + + expect(createElement).toHaveBeenCalledWith("a"); + expect(anchor.download).toBe("team_usage_daily_2025-01-01_2025-01-31.csv"); + expect(anchor.href).toBe("blob:mock"); + expect(click).toHaveBeenCalledOnce(); + expect(revoke).toHaveBeenCalledWith("blob:mock"); }); }); diff --git a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts index 95ce584cc89..0f2768c8417 100644 --- a/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts +++ b/ui/litellm-dashboard/src/components/EntityUsageExport/utils.ts @@ -1,497 +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, - entityMetadata?: Record, -): { 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): Record => { - const apiKeys = breakdown.api_keys; - if (!apiKeys || Object.keys(apiKeys).length === 0) return {}; - - const grouped: Record = {}; - - for (const [keyId, keyData] of Object.entries(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): Record => { - const entities = breakdown.entities; - if (entities && Object.keys(entities).length > 0) return entities; - return aggregateApiKeysIntoEntities(breakdown); -}; - -export const getEntityBreakdown = ( - spendData: EntitySpendData, - teamAliasMap: Record = {}, -): 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 = {}, -): 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 = { - 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 = {}, -): 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 = {}, -): any[] => { - const aggregatedData: { - [key: string]: { - Date: string; - entityId: string; - entityAlias: string; - userId: string; - userEmail: string | null; - keyIds: Set; - 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 = {}, -): any[] => { - const dailyModelBreakdown: any[] = []; - - spendData.results.forEach((day) => { - const dailyEntityModels: { [key: string]: { [key: string]: any } } = {}; - const dailyEntityMetadata: { [key: string]: Record | 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 = {}, -): 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; - } - 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 = {}, -): 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 = {}, -): 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); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.integration.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.integration.test.tsx new file mode 100644 index 00000000000..891690700e3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.integration.test.tsx @@ -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((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( + , + ); + + 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((resolve) => { + resolveDetail = resolve; + }); + const fetchKeyDetail = vi.fn().mockReturnValue(detailResponse); + render( + , + ); + + 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( + , + ); + + 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(); + + 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(); + + 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( + , + ); + + 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((resolve) => { + resolveRetry = resolve; + }), + ); + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + 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((resolve) => { + resolveFirstPage = resolve; + }), + ); + render( + , + ); + + 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( + , + ); + 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(); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "remote" } }); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(searchKeys).toHaveBeenCalledTimes(1); + + rerender(); + 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( + , + ); + 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( + , + ); + 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( + , + ); + 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((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(); + rerender(); + + 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); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx deleted file mode 100644 index 693ac20a360..00000000000 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx +++ /dev/null @@ -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 }) => ( -
    - {Object.keys(modelMetrics).map((hash) => ( -
  • {hash}
  • - ))} -
- ), -})); - -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 = { - "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(); - 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(); - 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(); - 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(); - 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"); - }); -}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx index 8287a04d0c7..47226b1c2e4 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx @@ -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; +type FetchKeyDetail = (apiKey: string) => Promise; +type SearchKeys = (search: string) => Promise; + interface KeyActivityPanelProps { - keyMetrics: Record; + summary: ModelActivityData; + summaryLoading?: boolean; + fetchKeyPage: FetchKeyPage; + fetchKeyDetail: FetchKeyDetail; + searchKeys: SearchKeys; + teams: Team[]; hidePromptCachingMetrics?: boolean; } -const KeyActivityPanel: React.FC = ({ 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 = ({ apiKey, detail, hidePromptCachingMetrics, onRetry }) => { + if (detail?.status === "loading") { + return

Loading key details...

; + } + if (detail?.status === "failed") { + return ( +

+ Could not load key details.{" "} + +

+ ); + } + if (detail?.status !== "loaded" || detail.metrics === undefined) { + return

No daily details available

; + } + return ( + + ); +}; + +const KeyActivityPanel: React.FC = ({ + 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(null); + const [detailState, setDetailState] = useState<{ + scope: FetchKeyPage; + details: Record; + } | 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(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 = ( +

+ Could not load keys for this range.{" "} + +

+ ); + const emptyListBody = (() => { + if (failed) return firstPageError; + if (!loading) return null; + return

Loading keys...

; + })(); + const keyList = Object.entries(filteredMetrics).map(([apiKey, metrics]) => { + const detail = visibleDetails[apiKey]; + return ( + loadKeyDetail(apiKey)} + header={ +
+ {metrics.label} + + + $ + {metrics.total_spend.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + + {metrics.total_requests.toLocaleString()} requests + +
+ } + > + loadKeyDetail(apiKey)} + /> +
+ ); + }); + 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 = ( + + ); + const emptyFilterBody = searchFailed ? ( +

+ Could not search keys. {searchRetryButton} +

+ ) : ( +

+ No keys match "{trimmedQuery}" in this date range +

+ ); + + 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 (
-
+ {summaryLoading ? ( + + ) : ( + + )} +
@@ -30,27 +400,36 @@ const KeyActivityPanel: React.FC = ({ 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 && ( - setQuery("")}> + updateQuery("")}> )} - - Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys - + {!pageUnavailable && ( + + {keyCountLabel} + + )}
- {isFiltering && totalKeys > 0 && shownKeys === 0 ? ( -

- No keys match "{query.trim()}" in this date range -

- ) : ( - + {noMatches ? emptyFilterBody :
{listBody}
} + {showSearchErrorNote && ( +

Could not search all keys. {searchRetryButton}

)} + {loadingMore &&

Loading more keys...

} + {showNextPageRetry && ( +

+ Could not load more keys.{" "} + +

+ )} + {!isFiltering && ); }; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/keySearch.ts b/ui/litellm-dashboard/src/components/UsagePage/components/keySearch.ts new file mode 100644 index 00000000000..fc0772f159c --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/components/keySearch.ts @@ -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 => + 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]; + }), + ); diff --git a/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts new file mode 100644 index 00000000000..36c2837d9ac --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/dailyActivityApi.ts @@ -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 }; + }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.test.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.test.ts new file mode 100644 index 00000000000..b35ea72896a --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.test.ts @@ -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"]); + }); +}); diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.ts new file mode 100644 index 00000000000..4b2e8c573f2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityData.ts @@ -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, + 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]; diff --git a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts index ce181f6b0c6..fbf45139e95 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/keyActivityFilter.test.ts @@ -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: [], }; diff --git a/ui/litellm-dashboard/src/components/UsagePage/types.ts b/ui/litellm-dashboard/src/components/UsagePage/types.ts index 6d0e0642ba2..06f1856a5a6 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/types.ts +++ b/ui/litellm-dashboard/src/components/UsagePage/types.ts @@ -54,16 +54,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; @@ -87,7 +77,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; diff --git a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx index 569f69adb56..8ce18884718 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx @@ -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 { expect(tokenElements.length).toBeGreaterThan(0); }); - it("should not display Top Virtual Keys section when model has no top_api_keys", () => { - render(); - 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( + , + ); + + 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 = { - "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(); - render(); - 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 = { - "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(); - render(); - expect(screen.getByText(/key-123456/)).toBeInTheDocument(); + expect(await screen.findByText("Old Scope Key")).toBeInTheDocument(); + expect(firstFetch).toHaveBeenCalledWith("gpt-4"); + + rerender(); + + 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 = { - "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(); - render(); - 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(); + 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(); - // 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", () => { diff --git a/ui/litellm-dashboard/src/components/activity_metrics.tsx b/ui/litellm-dashboard/src/components/activity_metrics.tsx index 5ddb89703ea..e7712a99c71 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.tsx @@ -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; + summaryMetrics?: ModelActivityData; + summaryTitle?: string; hidePromptCachingMetrics?: boolean; + fetchTopApiKeys?: (model: string) => Promise; } 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; +}) => { + 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; + 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 ( + + +

Top Virtual Keys by Spend

+

Loading top keys...

+
+
+ ); + } + + if (current.failed) { + return ( + + +

Top Virtual Keys by Spend

+

+ Could not load top keys.{" "} + +

+
+
+ ); + } + + const rows = current.rows; + if (rows.length === 0) return null; + + return ( + + +

Top Virtual Keys by Spend

+
+
+ {rows.map((keyData) => { + const keyLabel = keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`; + return ( +
+
+

{keyLabel}

+ {keyData.team_id &&

Team: {keyData.team_id}

} + {keyData.user && keyData.user !== keyLabel && ( +

User: {keyData.user}

+ )} +
+
+

${formatNumberWithCommas(keyData.spend, 2)}

+

+ {keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens +

+
+
+ ); + })} +
+
+
+
+ ); +}; + +export const ModelSection = ({ modelName, metrics, hidePromptCachingMetrics = false, + fetchTopApiKeys, }: { modelName: string; metrics: ModelActivityData; hidePromptCachingMetrics?: boolean; + fetchTopApiKeys?: (model: string) => Promise; }) => { return (
@@ -96,37 +228,7 @@ const ModelSection = ({
- {metrics.top_api_keys && metrics.top_api_keys.length > 0 && ( - - -

Top Virtual Keys by Spend

-
-
- {metrics.top_api_keys.map((keyData) => { - const keyLabel = keyData.key_alias || `${keyData.api_key.substring(0, 10)}...`; - return ( -
-
-

{keyLabel}

- {keyData.team_id &&

Team: {keyData.team_id}

} - {keyData.user && keyData.user !== keyLabel && ( -

User: {keyData.user}

- )} -
-
-

${formatNumberWithCommas(keyData.spend, 2)}

-

- {keyData.requests.toLocaleString()} requests | {keyData.tokens.toLocaleString()} tokens -

-
-
- ); - })} -
-
-
-
- )} + {fetchTopApiKeys && } {metrics.top_models && metrics.top_models.length > 0 && } @@ -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 ( { 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 = ({ modelMetrics, hidePromptCachingMetrics = false }) => { +export const ActivityMetrics: React.FC = ({ + 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 = ({ 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 (
{/* Global Summary */}
-

Overall Usage

+

{summaryTitle}

Total Requests

-

{totalMetrics.total_requests.toLocaleString()}

+

{totalRequests.toLocaleString()}

Total Successful Requests

-

- {totalMetrics.total_successful_requests.toLocaleString()} -

+

{totalSuccessfulRequests.toLocaleString()}

Total Tokens

-

{totalMetrics.total_tokens.toLocaleString()}

+

{totalTokens.toLocaleString()}

Total Spend

-

- ${formatNumberWithCommas(totalMetrics.total_spend, 2)} -

+

${formatNumberWithCommas(totalSpend, 2)}

@@ -463,40 +582,49 @@ export const ActivityMetrics: React.FC = ({ modelMetrics,
{/* Individual Model Sections */} -
- {modelNames.map((modelName) => ( - -

- {modelMetrics[modelName].label || "Unknown Item"} -

-
- ${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)} - {modelMetrics[modelName].total_requests.toLocaleString()} requests - {modelAverageResponseTimeMs(modelMetrics[modelName]) != null && ( - {formatResponseTime(modelAverageResponseTimeMs(modelMetrics[modelName]))} avg response - )} + {modelNames.length > 0 && ( +
+ {modelNames.map((modelName) => ( + +

+ {modelMetrics[modelName].label || "Unknown Item"} +

+
+ ${formatNumberWithCommas(modelMetrics[modelName].total_spend, 2)} + {modelMetrics[modelName].total_requests.toLocaleString()} requests + {modelAverageResponseTimeMs(modelMetrics[modelName]) != null && ( + + {formatResponseTime(modelAverageResponseTimeMs(modelMetrics[modelName]))} avg response + + )} +
-
- } - > - -
- ))} -
+ } + > + + + ))} +
+ )}
); }; // Helper function to format key label -export const formatKeyLabel = (modelData: KeyMetricWithMetadata, model: string, teams: Team[]): string => { +export const formatKeyLabel = ( + modelData: Pick, + 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 = {}; - - // 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, _]) => { diff --git a/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx b/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx index b6c6b78fe0b..e839890a357 100644 --- a/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx +++ b/ui/litellm-dashboard/src/components/chat/UsagePanel.tsx @@ -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 = ({ 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, }); diff --git a/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts new file mode 100644 index 00000000000..6821d26fb33 --- /dev/null +++ b/ui/litellm-dashboard/src/components/networking.dailyActivity.test.ts @@ -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().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, 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 => ({ + 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"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/networking.test.ts b/ui/litellm-dashboard/src/components/networking.test.ts index b964231804e..d5e0b21251e 100644 --- a/ui/litellm-dashboard/src/components/networking.test.ts +++ b/ui/litellm-dashboard/src/components/networking.test.ts @@ -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().mockResolvedValue( - new Response(JSON.stringify({ results: [], metadata: {} }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), - ); - global.fetch = mockFetch; - return mockFetch; - }; - - const requestedUrl = (mockFetch: ReturnType): 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; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index da902d4a9e2..20037c9b12b 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -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 = { + 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, -) => { - 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> = { + 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; +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 => + apiClient.get(`/${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 => + apiClient.get(`/${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 => + apiClient.get(`/${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 => + apiClient.get(`/${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 => + 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 => + apiClient.get(`/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. diff --git a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx deleted file mode 100644 index 93d464b6615..00000000000 --- a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.test.tsx +++ /dev/null @@ -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( - , - ); - - 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( - , - ); - - 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( - , - ); - - 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( - , - ); - - 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( - , - ); - - 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( - , - ); - - expect(screen.getByText(/Currently fetching agent data: fetched 1 \/ 3 pages/)).toBeInTheDocument(); - }); - - it("renders nothing when idle", () => { - const { container } = render( - , - ); - - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx b/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx deleted file mode 100644 index e9357993dd0..00000000000 --- a/ui/litellm-dashboard/src/components/shared/PaginationStatusAlerts.tsx +++ /dev/null @@ -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 && ( - - - - - 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,{" "} - - open a new tab - - . - - - - - )} - {failed && ( - - {failureMessage(subject, progress)} - - )} - {cancelled && !failed && ( - - - Showing partial {subject} ({progress.currentPage}/{progress.totalPages} pages loaded) - - - )} - -); - -export default PaginationStatusAlerts; diff --git a/ui/litellm-dashboard/src/components/shared/ScopedSavingsTab.tsx b/ui/litellm-dashboard/src/components/shared/ScopedSavingsTab.tsx index 7a0455f9d7e..514ecc40004 100644 --- a/ui/litellm-dashboard/src/components/shared/ScopedSavingsTab.tsx +++ b/ui/litellm-dashboard/src/components/shared/ScopedSavingsTab.tsx @@ -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); diff --git a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx index f6fea8ebe91..3567388de05 100644 --- a/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/KeySavingsTab.integration.test.tsx @@ -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 => ({ @@ -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, }); diff --git a/ui/litellm-dashboard/src/lib/http/client.test.ts b/ui/litellm-dashboard/src/lib/http/client.test.ts index 0d765371e0c..3d28c154990 100644 --- a/ui/litellm-dashboard/src/lib/http/client.test.ts +++ b/ui/litellm-dashboard/src/lib/http/client.test.ts @@ -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: () => "" }); diff --git a/ui/litellm-dashboard/src/lib/http/client.ts b/ui/litellm-dashboard/src/lib/http/client.ts index c78b2b10011..33d54c0fa28 100644 --- a/ui/litellm-dashboard/src/lib/http/client.ts +++ b/ui/litellm-dashboard/src/lib/http/client.ts @@ -111,6 +111,7 @@ export interface ApiClientConfig { export interface ApiClient { request(method: HttpMethod, path: string, options?: RequestOptions): Promise; get(path: string, options?: RequestOptions): Promise; + getBlob(path: string, options?: RequestOptions): Promise; post(path: string, options?: RequestOptions): Promise; put(path: string, options?: RequestOptions): Promise; delete(path: string, options?: RequestOptions): Promise; @@ -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(method: HttpMethod, path: string, options: RequestOptions = {}): Promise { + async function fetchChecked(method: HttpMethod, path: string, options: RequestOptions = {}): Promise { 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(method: HttpMethod, path: string, options: RequestOptions = {}): Promise { + 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 { + 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), From 3a91029328aeb81dba11236d6427649ce35cbbe5 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Thu, 1 Oct 2026 17:06:08 -0700 Subject: [PATCH 4/4] build(docker): drop the no-op PROXY_EXTRAS_SOURCE switch from the non-root image (#44097) uv sync --frozen ignores --no-sources-package, so the "published" branch already installed litellm-proxy-extras from the workspace (the shipped main-stable non-root image records file:///app/litellm-proxy-extras in its direct_url.json). Both branches ran the same install. Keep the single uv sync that the main and database images use. --- docker-compose.hardened.yml | 2 -- docker/Dockerfile.non_root | 31 ++++++++----------------------- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/docker-compose.hardened.yml b/docker-compose.hardened.yml index 31d0c2e9ef2..84a23faa054 100644 --- a/docker-compose.hardened.yml +++ b/docker-compose.hardened.yml @@ -6,8 +6,6 @@ services: context: . dockerfile: docker/Dockerfile.non_root target: runtime - args: - PROXY_EXTRAS_SOURCE: "local" depends_on: - squid user: "101:101" diff --git a/docker/Dockerfile.non_root b/docker/Dockerfile.non_root index eca12855afa..ca526e06834 100644 --- a/docker/Dockerfile.non_root +++ b/docker/Dockerfile.non_root @@ -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