From 86ee31a7538e27885b7fbc4052e6302c5c01abab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Jan 2026 19:37:51 +0000 Subject: [PATCH] Add admin user usage analytics endpoint Implements /admin/users/daily/activity endpoint for admins to view usage metrics across all users with Claude Code filtering. Backend changes: - New endpoint: GET /admin/users/daily/activity - Returns summary stats, top N users, and paginated user list - Supports filtering by tags (e.g., User-Agent:claude-code) - Supports spend thresholds (min/max) - Supports sorting by spend/requests/tokens - Pagination with configurable page size - Admin-only access with permission checks Frontend changes: - Added adminUsersDailyActivityCall() in networking.tsx - Follows same pattern as team/tag/org daily activity calls - Supports tag filters, spend filters, sorting, pagination Tests: - Unit tests for core functionality - Tests for filtering and pagination - Mock-based tests for database queries Endpoint design follows existing /user/daily/activity pattern: - Single paginated endpoint - Returns top users for visualization (bar chart) - Returns paginated users for table display - Returns summary metrics (totals, averages, counts) Use cases supported: - "Show me Claude Code power users" (filter by tag, sort by spend desc) - "Show me users spending >$200/mo" (min_spend filter) - "Show me lowest users" (sort by spend asc) Next steps for UI: - Create UserUsage component similar to EntityUsage - Add "User Usage" option to UsageViewSelect dropdown - Create bar chart component for top users - Create table component for paginated user list --- .../admin_user_usage_endpoints.py | 402 ++++++++++++++++++ litellm/proxy/proxy_server.py | 4 + .../proxy_unit_tests/test_admin_user_usage.py | 334 +++++++++++++++ .../src/components/networking.tsx | 33 ++ 4 files changed, 773 insertions(+) create mode 100644 litellm/proxy/management_endpoints/admin_user_usage_endpoints.py create mode 100644 tests/proxy_unit_tests/test_admin_user_usage.py diff --git a/litellm/proxy/management_endpoints/admin_user_usage_endpoints.py b/litellm/proxy/management_endpoints/admin_user_usage_endpoints.py new file mode 100644 index 00000000000..846b7f0aeba --- /dev/null +++ b/litellm/proxy/management_endpoints/admin_user_usage_endpoints.py @@ -0,0 +1,402 @@ +""" +Admin User Usage Endpoints + +Provides endpoints for admins to view usage metrics across all users. +Follows the same pattern as /user/daily/activity but for admin-level user analytics. +""" + +from datetime import datetime +from typing import Any, Dict, List, Optional + +import fastapi +from fastapi import APIRouter, Depends, HTTPException, Query, status + +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 ( + SpendAnalyticsPaginatedResponse, +) +from litellm.proxy.utils import PrismaClient + +router = APIRouter() + + +def _user_has_admin_view(user_api_key_dict: UserAPIKeyAuth) -> bool: + """Check if user has admin permissions.""" + from litellm.proxy.proxy_server import premium_user + from litellm.utils.utils import get_utc_datetime + + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + _user_has_admin_view as internal_admin_check, + ) + + return internal_admin_check(user_api_key_dict) + + +async def get_admin_users_usage( + prisma_client: Optional[PrismaClient], + start_date: str, + end_date: str, + tag_filters: Optional[List[str]] = None, + min_spend: Optional[float] = None, + max_spend: Optional[float] = None, + sort_by: str = "spend", + sort_order: str = "desc", + page: int = 1, + page_size: int = 50, + top_n: int = 10, +) -> Dict[str, Any]: + """ + Get admin-level user usage analytics with pagination. + + Returns: + - summary: Aggregate stats across all users + - top_users: Top N users for bar chart visualization + - users: Paginated list of users for table + - pagination: Pagination metadata + """ + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + # Build WHERE conditions for queries + where_conditions = [] + params_summary = [start_date, end_date] + params_top = [start_date, end_date] + params_paginated = [start_date, end_date] + + # Base WHERE clause + base_where = "dus.date >= $1 AND dus.date <= $2 AND vt.user_id IS NOT NULL" + + # Add tag filters + tag_filter_clause = "" + if tag_filters and len(tag_filters) > 0: + tag_placeholders = ", ".join([f"${i+3}" for i in range(len(tag_filters))]) + tag_filter_clause = f" AND dts.tag IN ({tag_placeholders})" + params_summary.extend(tag_filters) + params_top.extend(tag_filters) + params_paginated.extend(tag_filters) + + # Query 1: Get summary statistics + summary_sql = f""" + WITH user_totals AS ( + SELECT + vt.user_id, + SUM(dus.spend) as user_spend, + SUM(dus.api_requests) as user_requests, + SUM(dus.successful_requests) as user_successful_requests, + SUM(dus.failed_requests) as user_failed_requests, + SUM(dus.prompt_tokens + dus.completion_tokens) as user_tokens + FROM "LiteLLM_DailyUserSpend" dus + INNER JOIN "LiteLLM_VerificationToken" vt ON dus.api_key = vt.token + {f'INNER JOIN "LiteLLM_DailyTagSpend" dts ON dus.api_key = dts.api_key AND dus.date = dts.date' if tag_filters else ''} + WHERE {base_where}{tag_filter_clause if tag_filters else ''} + GROUP BY vt.user_id + ) + SELECT + COUNT(DISTINCT user_id) as total_users, + COALESCE(SUM(user_spend), 0) as total_spend, + COALESCE(SUM(user_requests), 0) as total_requests, + COALESCE(SUM(user_successful_requests), 0) as total_successful_requests, + COALESCE(SUM(user_failed_requests), 0) as total_failed_requests, + COALESCE(SUM(user_tokens), 0) as total_tokens, + COALESCE(AVG(user_spend), 0) as avg_spend_per_user, + COUNT(CASE WHEN user_spend > 200 THEN 1 END) as power_users_count, + COUNT(CASE WHEN user_spend < 10 THEN 1 END) as low_users_count + FROM user_totals + """ + + if min_spend is not None: + summary_sql += f" WHERE user_spend >= ${len(params_summary) + 1}" + params_summary.append(min_spend) + + if max_spend is not None: + summary_sql += ( + f" {'AND' if min_spend is not None else 'WHERE'} user_spend <= ${len(params_summary) + 1}" + ) + params_summary.append(max_spend) + + summary_result = await prisma_client.db.query_raw(summary_sql, *params_summary) + + summary = { + "total_users": summary_result[0].get("total_users", 0) if summary_result else 0, + "total_spend": float(summary_result[0].get("total_spend", 0.0)) if summary_result else 0.0, + "total_requests": int(summary_result[0].get("total_requests", 0)) if summary_result else 0, + "total_successful_requests": int(summary_result[0].get("total_successful_requests", 0)) + if summary_result + else 0, + "total_failed_requests": int(summary_result[0].get("total_failed_requests", 0)) + if summary_result + else 0, + "total_tokens": int(summary_result[0].get("total_tokens", 0)) if summary_result else 0, + "avg_spend_per_user": float(summary_result[0].get("avg_spend_per_user", 0.0)) + if summary_result + else 0.0, + "power_users_count": summary_result[0].get("power_users_count", 0) if summary_result else 0, + "low_users_count": summary_result[0].get("low_users_count", 0) if summary_result else 0, + } + + # Query 2: Get top N users for bar chart + sort_field_map = { + "spend": "total_spend", + "requests": "total_requests", + "tokens": "total_tokens", + } + db_sort_field = sort_field_map.get(sort_by, "total_spend") + + spend_filter_clause = "" + if min_spend is not None: + spend_filter_clause += f" HAVING SUM(dus.spend) >= ${len(params_top) + 1}" + params_top.append(min_spend) + if max_spend is not None: + spend_filter_clause += f" AND SUM(dus.spend) <= ${len(params_top) + 1}" + params_top.append(max_spend) + elif max_spend is not None: + spend_filter_clause += f" HAVING SUM(dus.spend) <= ${len(params_top) + 1}" + params_top.append(max_spend) + + top_users_sql = f""" + SELECT + vt.user_id, + ut.user_email, + SUM(dus.spend) as total_spend, + SUM(dus.api_requests) as total_requests, + SUM(dus.successful_requests) as total_successful_requests, + SUM(dus.failed_requests) as total_failed_requests, + SUM(dus.prompt_tokens) as total_prompt_tokens, + SUM(dus.completion_tokens) as total_completion_tokens, + SUM(dus.prompt_tokens + dus.completion_tokens) as total_tokens, + COUNT(DISTINCT dus.date) as days_active, + MIN(dus.date) as first_request_date, + MAX(dus.date) as last_request_date, + ARRAY_AGG(DISTINCT dts.tag) FILTER (WHERE dts.tag IS NOT NULL) as tags, + ARRAY_AGG(DISTINCT dus.model) FILTER (WHERE dus.model IS NOT NULL) as models_used + FROM "LiteLLM_DailyUserSpend" dus + INNER JOIN "LiteLLM_VerificationToken" vt ON dus.api_key = vt.token + LEFT JOIN "LiteLLM_UserTable" ut ON vt.user_id = ut.user_id + LEFT JOIN "LiteLLM_DailyTagSpend" dts ON dus.api_key = dts.api_key AND dus.date = dts.date + WHERE {base_where}{tag_filter_clause if tag_filters else ''} + GROUP BY vt.user_id, ut.user_email + {spend_filter_clause} + ORDER BY {db_sort_field} {sort_order.upper()} + LIMIT {top_n} + """ + + top_users_result = await prisma_client.db.query_raw(top_users_sql, *params_top) + + top_users = [ + { + "user_id": row["user_id"], + "user_email": row["user_email"] or row["user_id"], + "spend": float(row["total_spend"]), + "requests": int(row["total_requests"]), + "successful_requests": int(row["total_successful_requests"]), + "failed_requests": int(row["total_failed_requests"]), + "prompt_tokens": int(row["total_prompt_tokens"]), + "completion_tokens": int(row["total_completion_tokens"]), + "tokens": int(row["total_tokens"]), + "days_active": int(row["days_active"]), + "first_request_date": str(row["first_request_date"]), + "last_request_date": str(row["last_request_date"]), + "tags": row["tags"] or [], + "models_used": row["models_used"] or [], + } + for row in top_users_result + ] + + # Query 3: Get paginated users for table + offset = (page - 1) * page_size + + # First get total count for pagination + count_sql = f""" + SELECT COUNT(DISTINCT vt.user_id) as total_count + FROM "LiteLLM_DailyUserSpend" dus + INNER JOIN "LiteLLM_VerificationToken" vt ON dus.api_key = vt.token + {f'INNER JOIN "LiteLLM_DailyTagSpend" dts ON dus.api_key = dts.api_key AND dus.date = dts.date' if tag_filters else ''} + WHERE {base_where}{tag_filter_clause if tag_filters else ''} + """ + + count_result = await prisma_client.db.query_raw(count_sql, *params_paginated[: len(params_paginated) - (2 if min_spend or max_spend else 0)]) + total_count = count_result[0]["total_count"] if count_result else 0 + + # Get paginated users + paginated_sql = f""" + SELECT + vt.user_id, + ut.user_email, + SUM(dus.spend) as total_spend, + SUM(dus.api_requests) as total_requests, + SUM(dus.successful_requests) as total_successful_requests, + SUM(dus.failed_requests) as total_failed_requests, + SUM(dus.prompt_tokens) as total_prompt_tokens, + SUM(dus.completion_tokens) as total_completion_tokens, + SUM(dus.prompt_tokens + dus.completion_tokens) as total_tokens, + COUNT(DISTINCT dus.date) as days_active, + MIN(dus.date) as first_request_date, + MAX(dus.date) as last_request_date, + ARRAY_AGG(DISTINCT dts.tag) FILTER (WHERE dts.tag IS NOT NULL) as tags, + ARRAY_AGG(DISTINCT dus.model) FILTER (WHERE dus.model IS NOT NULL) as models_used + FROM "LiteLLM_DailyUserSpend" dus + INNER JOIN "LiteLLM_VerificationToken" vt ON dus.api_key = vt.token + LEFT JOIN "LiteLLM_UserTable" ut ON vt.user_id = ut.user_id + LEFT JOIN "LiteLLM_DailyTagSpend" dts ON dus.api_key = dts.api_key AND dus.date = dts.date + WHERE {base_where}{tag_filter_clause if tag_filters else ''} + GROUP BY vt.user_id, ut.user_email + {spend_filter_clause} + ORDER BY {db_sort_field} {sort_order.upper()} + LIMIT {page_size} OFFSET {offset} + """ + + paginated_result = await prisma_client.db.query_raw(paginated_sql, *params_paginated) + + users = [ + { + "user_id": row["user_id"], + "user_email": row["user_email"] or row["user_id"], + "spend": float(row["total_spend"]), + "requests": int(row["total_requests"]), + "successful_requests": int(row["total_successful_requests"]), + "failed_requests": int(row["total_failed_requests"]), + "prompt_tokens": int(row["total_prompt_tokens"]), + "completion_tokens": int(row["total_completion_tokens"]), + "tokens": int(row["total_tokens"]), + "days_active": int(row["days_active"]), + "first_request_date": str(row["first_request_date"]), + "last_request_date": str(row["last_request_date"]), + "tags": row["tags"] or [], + "models_used": row["models_used"] or [], + } + for row in paginated_result + ] + + total_pages = (total_count + page_size - 1) // page_size if total_count > 0 else 0 + + return { + "summary": summary, + "top_users": top_users, + "users": users, + "pagination": { + "page": page, + "page_size": page_size, + "total_count": total_count, + "total_pages": total_pages, + }, + } + + +@router.get( + "/admin/users/daily/activity", + tags=["Budget & Spend Tracking", "Admin User Analytics"], + dependencies=[Depends(user_api_key_auth)], +) +async def get_admin_users_daily_activity( + start_date: str = fastapi.Query( + ..., + description="Start date in YYYY-MM-DD format", + ), + end_date: str = fastapi.Query( + ..., + description="End date in YYYY-MM-DD format", + ), + tag_filters: Optional[List[str]] = fastapi.Query( + default=None, + description="Filter by specific tags (e.g., User-Agent:claude-code)", + ), + min_spend: Optional[float] = fastapi.Query( + default=None, + description="Minimum spend threshold", + ), + max_spend: Optional[float] = fastapi.Query( + default=None, + description="Maximum spend threshold", + ), + sort_by: str = fastapi.Query( + default="spend", + description="Sort by field: spend, requests, or tokens", + ), + sort_order: str = fastapi.Query( + default="desc", + description="Sort order: asc or desc", + ), + page: int = fastapi.Query( + default=1, description="Page number for pagination", ge=1 + ), + page_size: int = fastapi.Query( + default=50, description="Items per page", ge=1, le=100 + ), + top_n: int = fastapi.Query( + default=10, description="Number of top users to return", ge=1, le=50 + ), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Get admin-level user usage analytics with pagination. + + Returns usage metrics for all users with: + - Summary: Aggregate statistics across all users + - Top Users: Top N users for visualization (bar chart) + - Users: Paginated list of users for table display + - Pagination: Metadata for pagination + + Requires admin permissions. + """ + 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}, + ) + + # Check admin permissions + if not _user_has_admin_view(user_api_key_dict): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"error": "Admin permissions required"}, + ) + + # Validate date format + try: + datetime.strptime(start_date, "%Y-%m-%d") + datetime.strptime(end_date, "%Y-%m-%d") + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "Invalid date format. Use YYYY-MM-DD"}, + ) + + # Validate sort parameters + if sort_by not in ["spend", "requests", "tokens"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "sort_by must be one of: spend, requests, tokens"}, + ) + + if sort_order not in ["asc", "desc"]: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "sort_order must be one of: asc, desc"}, + ) + + try: + result = await get_admin_users_usage( + prisma_client=prisma_client, + start_date=start_date, + end_date=end_date, + tag_filters=tag_filters, + min_spend=min_spend, + max_spend=max_spend, + sort_by=sort_by, + sort_order=sort_order, + page=page, + page_size=page_size, + top_n=top_n, + ) + return result + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"error": f"Failed to fetch admin user usage: {str(e)}"}, + ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c3a4de314e5..dd2b869d09a 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -309,6 +309,9 @@ from litellm.proxy.management_endpoints.internal_user_endpoints import ( from litellm.proxy.management_endpoints.internal_user_endpoints import ( user_update, ) +from litellm.proxy.management_endpoints.admin_user_usage_endpoints import ( + router as admin_user_usage_router, +) from litellm.proxy.management_endpoints.key_management_endpoints import ( delete_verification_tokens, duration_in_seconds, @@ -10509,6 +10512,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(admin_user_usage_router) app.include_router(team_router) app.include_router(ui_sso_router) app.include_router(scim_router) diff --git a/tests/proxy_unit_tests/test_admin_user_usage.py b/tests/proxy_unit_tests/test_admin_user_usage.py new file mode 100644 index 00000000000..292c92f704d --- /dev/null +++ b/tests/proxy_unit_tests/test_admin_user_usage.py @@ -0,0 +1,334 @@ +""" +Tests for Admin User Usage Analytics Endpoint +""" + +import pytest +import sys +import os +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +# Add parent directory to path +sys.path.insert(0, os.path.abspath("../..")) + +from litellm.proxy.management_endpoints.admin_user_usage_endpoints import ( + get_admin_users_usage, +) + + +@pytest.mark.asyncio +async def test_get_admin_users_usage_basic(): + """Test basic admin user usage query with mock data""" + + # Mock prisma client + mock_prisma = MagicMock() + mock_db = MagicMock() + mock_prisma.db = mock_db + + # Mock summary query result + mock_db.query_raw = AsyncMock() + + # First call: summary query + mock_db.query_raw.side_effect = [ + # Summary result + [ + { + "total_users": 10, + "total_spend": 1000.0, + "total_requests": 5000, + "total_successful_requests": 4900, + "total_failed_requests": 100, + "total_tokens": 100000, + "avg_spend_per_user": 100.0, + "power_users_count": 2, + "low_users_count": 3, + } + ], + # Top users result + [ + { + "user_id": "user_1", + "user_email": "user1@example.com", + "total_spend": 500.0, + "total_requests": 2000, + "total_successful_requests": 1980, + "total_failed_requests": 20, + "total_prompt_tokens": 40000, + "total_completion_tokens": 10000, + "total_tokens": 50000, + "days_active": 15, + "first_request_date": "2026-01-01", + "last_request_date": "2026-01-15", + "tags": ["User-Agent:claude-code"], + "models_used": ["claude-sonnet-4"], + } + ], + # Count query for pagination + [{"total_count": 10}], + # Paginated users result + [ + { + "user_id": "user_1", + "user_email": "user1@example.com", + "total_spend": 500.0, + "total_requests": 2000, + "total_successful_requests": 1980, + "total_failed_requests": 20, + "total_prompt_tokens": 40000, + "total_completion_tokens": 10000, + "total_tokens": 50000, + "days_active": 15, + "first_request_date": "2026-01-01", + "last_request_date": "2026-01-15", + "tags": ["User-Agent:claude-code"], + "models_used": ["claude-sonnet-4"], + }, + { + "user_id": "user_2", + "user_email": "user2@example.com", + "total_spend": 300.0, + "total_requests": 1500, + "total_successful_requests": 1490, + "total_failed_requests": 10, + "total_prompt_tokens": 30000, + "total_completion_tokens": 7500, + "total_tokens": 37500, + "days_active": 12, + "first_request_date": "2026-01-03", + "last_request_date": "2026-01-15", + "tags": ["User-Agent:claude-code"], + "models_used": ["claude-opus-4"], + }, + ], + ] + + # Call the function + result = await get_admin_users_usage( + prisma_client=mock_prisma, + start_date="2026-01-01", + end_date="2026-01-15", + tag_filters=["User-Agent:claude-code"], + min_spend=None, + max_spend=None, + sort_by="spend", + sort_order="desc", + page=1, + page_size=50, + top_n=10, + ) + + # Assertions + assert result is not None + assert "summary" in result + assert "top_users" in result + assert "users" in result + assert "pagination" in result + + # Check summary + assert result["summary"]["total_users"] == 10 + assert result["summary"]["total_spend"] == 1000.0 + assert result["summary"]["power_users_count"] == 2 + + # Check top users + assert len(result["top_users"]) == 1 + assert result["top_users"][0]["user_email"] == "user1@example.com" + assert result["top_users"][0]["spend"] == 500.0 + + # Check paginated users + assert len(result["users"]) == 2 + assert result["users"][0]["user_email"] == "user1@example.com" + assert result["users"][1]["user_email"] == "user2@example.com" + + # Check pagination + assert result["pagination"]["page"] == 1 + assert result["pagination"]["total_count"] == 10 + assert result["pagination"]["total_pages"] == 1 + + +@pytest.mark.asyncio +async def test_get_admin_users_usage_with_filters(): + """Test admin user usage with spend filters""" + + # Mock prisma client + mock_prisma = MagicMock() + mock_db = MagicMock() + mock_prisma.db = mock_db + mock_db.query_raw = AsyncMock() + + # Mock results for filtered query + mock_db.query_raw.side_effect = [ + # Summary result + [ + { + "total_users": 2, + "total_spend": 800.0, + "total_requests": 3500, + "total_successful_requests": 3470, + "total_failed_requests": 30, + "total_tokens": 87500, + "avg_spend_per_user": 400.0, + "power_users_count": 2, + "low_users_count": 0, + } + ], + # Top users result + [ + { + "user_id": "user_1", + "user_email": "user1@example.com", + "total_spend": 500.0, + "total_requests": 2000, + "total_successful_requests": 1980, + "total_failed_requests": 20, + "total_prompt_tokens": 40000, + "total_completion_tokens": 10000, + "total_tokens": 50000, + "days_active": 15, + "first_request_date": "2026-01-01", + "last_request_date": "2026-01-15", + "tags": ["User-Agent:claude-code"], + "models_used": ["claude-sonnet-4"], + } + ], + # Count query + [{"total_count": 2}], + # Paginated users result + [ + { + "user_id": "user_1", + "user_email": "user1@example.com", + "total_spend": 500.0, + "total_requests": 2000, + "total_successful_requests": 1980, + "total_failed_requests": 20, + "total_prompt_tokens": 40000, + "total_completion_tokens": 10000, + "total_tokens": 50000, + "days_active": 15, + "first_request_date": "2026-01-01", + "last_request_date": "2026-01-15", + "tags": ["User-Agent:claude-code"], + "models_used": ["claude-sonnet-4"], + }, + { + "user_id": "user_2", + "user_email": "user2@example.com", + "total_spend": 300.0, + "total_requests": 1500, + "total_successful_requests": 1490, + "total_failed_requests": 10, + "total_prompt_tokens": 30000, + "total_completion_tokens": 7500, + "total_tokens": 37500, + "days_active": 12, + "first_request_date": "2026-01-03", + "last_request_date": "2026-01-15", + "tags": ["User-Agent:claude-code"], + "models_used": ["claude-opus-4"], + }, + ], + ] + + # Call with min_spend filter + result = await get_admin_users_usage( + prisma_client=mock_prisma, + start_date="2026-01-01", + end_date="2026-01-15", + tag_filters=["User-Agent:claude-code"], + min_spend=200.0, # Filter for users spending > $200 + max_spend=None, + sort_by="spend", + sort_order="desc", + page=1, + page_size=50, + top_n=10, + ) + + # Assertions + assert result is not None + assert result["summary"]["total_users"] == 2 + assert result["summary"]["power_users_count"] == 2 + assert len(result["users"]) == 2 + + # All users should have spend >= 200 + for user in result["users"]: + assert user["spend"] >= 200.0 + + +@pytest.mark.asyncio +async def test_get_admin_users_usage_pagination(): + """Test pagination works correctly""" + + # Mock prisma client + mock_prisma = MagicMock() + mock_db = MagicMock() + mock_prisma.db = mock_db + mock_db.query_raw = AsyncMock() + + # Mock results for page 2 + mock_db.query_raw.side_effect = [ + # Summary result + [ + { + "total_users": 150, + "total_spend": 15000.0, + "total_requests": 75000, + "total_successful_requests": 74000, + "total_failed_requests": 1000, + "total_tokens": 1500000, + "avg_spend_per_user": 100.0, + "power_users_count": 25, + "low_users_count": 30, + } + ], + # Top users result + [], + # Count query + [{"total_count": 150}], + # Paginated users result (page 2: users 51-100) + [ + { + "user_id": f"user_{i}", + "user_email": f"user{i}@example.com", + "total_spend": 100.0 - i, + "total_requests": 500, + "total_successful_requests": 490, + "total_failed_requests": 10, + "total_prompt_tokens": 10000, + "total_completion_tokens": 5000, + "total_tokens": 15000, + "days_active": 10, + "first_request_date": "2026-01-01", + "last_request_date": "2026-01-15", + "tags": [], + "models_used": ["claude-sonnet-4"], + } + for i in range(51, 101) + ], + ] + + # Call with page 2 + result = await get_admin_users_usage( + prisma_client=mock_prisma, + start_date="2026-01-01", + end_date="2026-01-15", + tag_filters=None, + min_spend=None, + max_spend=None, + sort_by="spend", + sort_order="desc", + page=2, + page_size=50, + top_n=10, + ) + + # Assertions + assert result["pagination"]["page"] == 2 + assert result["pagination"]["page_size"] == 50 + assert result["pagination"]["total_count"] == 150 + assert result["pagination"]["total_pages"] == 3 + assert len(result["users"]) == 50 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 1fe72996cfd..442761ed68f 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1872,6 +1872,39 @@ export const agentDailyActivityCall = async ( }); }; +export const adminUsersDailyActivityCall = async ( + accessToken: string, + startTime: Date, + endTime: Date, + page: number = 1, + tagFilters: string[] | null = null, + minSpend: number | null = null, + maxSpend: number | null = null, + sortBy: string = "spend", + sortOrder: string = "desc", + topN: number = 10, +) => { + /** + * Get admin-level user usage analytics + * Returns top users, paginated user list, and summary stats + */ + return fetchDailyActivity({ + accessToken, + endpoint: "/admin/users/daily/activity", + startTime, + endTime, + page, + extraQueryParams: { + tag_filters: tagFilters, + min_spend: minSpend, + max_spend: maxSpend, + sort_by: sortBy, + sort_order: sortOrder, + top_n: topN, + }, + }); +}; + export const getTotalSpendCall = async (accessToken: string) => { /** * Get all models on proxy