mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
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
This commit is contained in:
parent
a02c43d300
commit
86ee31a753
4 changed files with 773 additions and 0 deletions
402
litellm/proxy/management_endpoints/admin_user_usage_endpoints.py
Normal file
402
litellm/proxy/management_endpoints/admin_user_usage_endpoints.py
Normal file
|
|
@ -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)}"},
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
334
tests/proxy_unit_tests/test_admin_user_usage.py
Normal file
334
tests/proxy_unit_tests/test_admin_user_usage.py
Normal file
|
|
@ -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"])
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue