From f335bb91ddd9c9afc3df377312633df078863787 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Fri, 16 May 2025 10:04:02 -0700 Subject: [PATCH] Model filter added on logs tab. (#10877) (#10891) * model filter added * Make status logic it's own helper + add a unit test in test_spend_management_endpoints Co-authored-by: tanjiro <56165694+NANDINI-star@users.noreply.github.com> --- .../spend_management_endpoints.py | 39 +++- .../test_spend_management_endpoints.py | 187 ++++++++++++++++++ .../components/common_components/filter.tsx | 1 + .../src/components/networking.tsx | 5 +- .../src/components/view_logs/index.tsx | 28 ++- .../components/view_logs/log_filter_logic.tsx | 38 +++- 6 files changed, 284 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 499209ad907..453d09a2002 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1658,6 +1658,10 @@ async def ui_view_spend_logs( # noqa: PLR0915 default=None, description="Filter logs by status (e.g., success, failure)" ), + model: Optional[str] = fastapi.Query( + default=None, + description="Filter logs by model" + ), ): """ View spend logs for UI with pagination support @@ -1710,11 +1714,9 @@ async def ui_view_spend_logs( # noqa: PLR0915 if team_id is not None: where_conditions["team_id"] = team_id - if status_filter is not None: - if status_filter == "success": - where_conditions["status"] = {"in": ["success", None]} # Assuming None means empty status - else: - where_conditions["status"] = status_filter # Filtering for other status values + status_condition = _build_status_filter_condition(status_filter) + if status_condition: + where_conditions.update(status_condition) if api_key is not None: where_conditions["api_key"] = api_key @@ -1725,6 +1727,9 @@ async def ui_view_spend_logs( # noqa: PLR0915 if request_id is not None: where_conditions["request_id"] = request_id + if model is not None: + where_conditions["model"] = model + if min_spend is not None or max_spend is not None: where_conditions["spend"] = {} if min_spend is not None: @@ -2912,3 +2917,27 @@ async def ui_view_session_spend_logs( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e), ) + + +def _build_status_filter_condition(status_filter: Optional[str]) -> Dict[str, Any]: + """ + Helper function to build the status filter condition for database queries. + + Args: + status_filter (Optional[str]): The status to filter by. Can be "success" or "failure". + + Returns: + Dict[str, Any]: A dictionary containing the status filter condition. + """ + if status_filter is None: + return {} + + if status_filter == "success": + return { + "OR": [ + {"status": {"equals": "success"}}, + {"status": None} + ] + } + else: + return {"status": {"equals": status_filter}} diff --git a/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 9b86aca98b0..aa30794e92e 100644 --- a/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -425,6 +425,193 @@ async def test_ui_view_spend_logs_unauthorized(client): assert response.status_code == 401 or response.status_code == 403 +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_status(client, monkeypatch): + # Mock data for the test + mock_spend_logs = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "sk-test-key", + "user": "test_user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-3.5-turbo", + "status": "success" + }, + { + "id": "log2", + "request_id": "req2", + "api_key": "sk-test-key", + "user": "test_user_2", + "team_id": "team1", + "spend": 0.10, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "status": "failure" + }, + ] + + # Create a mock prisma client + class MockDB: + async def find_many(self, *args, **kwargs): + # Filter based on status in the where conditions + if "where" in kwargs: + where_conditions = kwargs["where"] + if "OR" in where_conditions: + # Handle success case (which includes None status) + return [mock_spend_logs[0]] + elif "status" in where_conditions and where_conditions["status"]["equals"] == "failure": + return [mock_spend_logs[1]] + return mock_spend_logs + + async def count(self, *args, **kwargs): + # Return count based on status filter + if "where" in kwargs: + where_conditions = kwargs["where"] + if "OR" in where_conditions: + return 1 + elif "status" in where_conditions and where_conditions["status"]["equals"] == "failure": + return 1 + return len(mock_spend_logs) + + class MockPrismaClient: + def __init__(self): + self.db = MockDB() + self.db.litellm_spendlogs = self.db + + # Apply the monkeypatch + mock_prisma_client = MockPrismaClient() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + # Set up test dates + start_date = ( + datetime.datetime.now(timezone.utc) - datetime.timedelta(days=7) + ).strftime("%Y-%m-%d %H:%M:%S") + end_date = datetime.datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + # Test success status + response = client.get( + "/spend/logs/ui", + params={ + "status_filter": "success", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["status"] == "success" + + # Test failure status + response = client.get( + "/spend/logs/ui", + params={ + "status_filter": "failure", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["status"] == "failure" + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_model(client, monkeypatch): + # Mock data for the test + mock_spend_logs = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "sk-test-key", + "user": "test_user_1", + "team_id": "team1", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-3.5-turbo", + "status": "success" + }, + { + "id": "log2", + "request_id": "req2", + "api_key": "sk-test-key", + "user": "test_user_2", + "team_id": "team1", + "spend": 0.10, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + "status": "success" + }, + ] + + # Create a mock prisma client + class MockDB: + async def find_many(self, *args, **kwargs): + # Filter based on model in the where conditions + if ( + "where" in kwargs + and "model" in kwargs["where"] + and kwargs["where"]["model"] == "gpt-3.5-turbo" + ): + return [mock_spend_logs[0]] + return mock_spend_logs + + async def count(self, *args, **kwargs): + # Return count based on model filter + if ( + "where" in kwargs + and "model" in kwargs["where"] + and kwargs["where"]["model"] == "gpt-3.5-turbo" + ): + return 1 + return len(mock_spend_logs) + + class MockPrismaClient: + def __init__(self): + self.db = MockDB() + self.db.litellm_spendlogs = self.db + + # Apply the monkeypatch + mock_prisma_client = MockPrismaClient() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + # Set up test dates + start_date = ( + datetime.datetime.now(timezone.utc) - datetime.timedelta(days=7) + ).strftime("%Y-%m-%d %H:%M:%S") + end_date = datetime.datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + + # Make the request with model filter + response = client.get( + "/spend/logs/ui", + params={ + "model": "gpt-3.5-turbo", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + # Assert response + assert response.status_code == 200 + data = response.json() + + # Verify the filtered data + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["model"] == "gpt-3.5-turbo" + + class TestSpendLogsPayload: @pytest.mark.asyncio async def test_spend_logs_payload_e2e(self): diff --git a/ui/litellm-dashboard/src/components/common_components/filter.tsx b/ui/litellm-dashboard/src/components/common_components/filter.tsx index 067fadfc6ba..8351ea791ad 100644 --- a/ui/litellm-dashboard/src/components/common_components/filter.tsx +++ b/ui/litellm-dashboard/src/components/common_components/filter.tsx @@ -88,6 +88,7 @@ const FilterComponent: React.FC = ({ "Key Alias", "User ID", "Key Hash", + "Model" ]; return ( diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index b4320d59f26..56029f51be2 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2147,7 +2147,8 @@ export const uiSpendLogsCall = async ( page?: number, page_size?: number, user_id?: string, - status_filter?: string + status_filter?: string, + model?: string ) => { try { // Construct base URL @@ -2164,7 +2165,7 @@ export const uiSpendLogsCall = async ( if (page_size) queryParams.append('page_size', page_size.toString()); if (user_id) queryParams.append('user_id', user_id); if (status_filter) queryParams.append('status_filter', status_filter); - + if (model) queryParams.append('model', model); // Append query parameters to URL if any exist const queryString = queryParams.toString(); if (queryString) { diff --git a/ui/litellm-dashboard/src/components/view_logs/index.tsx b/ui/litellm-dashboard/src/components/view_logs/index.tsx index fb2c20ee3b3..1a60d0b8f76 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.tsx @@ -73,6 +73,7 @@ export default function SpendLogsTable({ const [tempKeyHash, setTempKeyHash] = useState(""); const [selectedTeamId, setSelectedTeamId] = useState(""); const [selectedKeyHash, setSelectedKeyHash] = useState(""); + const [selectedModel, setSelectedModel] = useState(""); const [selectedKeyInfo, setSelectedKeyInfo] = useState(null); const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState(null); const [selectedStatus, setSelectedStatus] = useState(""); @@ -147,7 +148,8 @@ export default function SpendLogsTable({ selectedTeamId, selectedKeyHash, filterByCurrentUser ? userID : null, - selectedStatus + selectedStatus, + selectedModel ], queryFn: async () => { if (!accessToken || !token || !userRole || !userID) { @@ -177,7 +179,8 @@ export default function SpendLogsTable({ currentPage, pageSize, filterByCurrentUser ? userID : undefined, - selectedStatus + selectedStatus, + selectedModel ); // Trigger prefetch for all logs @@ -222,6 +225,7 @@ export default function SpendLogsTable({ filteredLogs, allTeams: hookAllTeams, allKeyAliases, + allModels, handleFilterChange, handleFilterReset } = useLogFilterLogic({ @@ -231,7 +235,9 @@ export default function SpendLogsTable({ endTime, pageSize, isCustomDate, - setCurrentPage + setCurrentPage, + userID, + userRole }) // Add this effect to update selectedTeamId and selectedStatus when team filter changes @@ -243,6 +249,7 @@ export default function SpendLogsTable({ setSelectedTeamId(""); } setSelectedStatus(filters['Status'] || ""); + setSelectedModel(filters['Model'] || ""); }, [filters]); // Fetch logs for a session if selected @@ -360,6 +367,21 @@ export default function SpendLogsTable({ { label: 'Success', value: 'success' }, { label: 'Failure', value: 'failure' } ] + }, + { + name: 'Model', + label: 'Model', + isSearchable: true, + searchFn: async (searchText: string) => { + if (!allModels || allModels.length === 0) return []; + const filtered = allModels.filter((model: string) => { + return model.toLowerCase().includes(searchText.toLowerCase()); + }); + return filtered.map((model: string) => ({ + label: model, + value: model + })); + } } ] diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 5480f1b7a9c..0ff5fd5be2e 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -1,6 +1,6 @@ import moment from "moment"; import { useCallback, useEffect, useState, useRef, useMemo } from "react"; -import { uiSpendLogsCall } from "../networking"; +import { modelAvailableCall, uiSpendLogsCall } from "../networking"; import { Team } from "../key_team_helpers/key_list"; import { useQuery } from "@tanstack/react-query"; import { fetchAllKeyAliases, fetchAllTeams } from "../../components/key_team_helpers/filter_helpers"; @@ -14,7 +14,7 @@ export const FILTER_KEYS = { REQUEST_ID: "Request ID", MODEL: "Model", USER_ID: "User ID", - STATUS: "Status" + STATUS: "Status", } as const; export type FilterKey = keyof typeof FILTER_KEYS; @@ -27,7 +27,9 @@ export function useLogFilterLogic({ endTime, // Receive from SpendLogsTable pageSize = defaultPageSize, isCustomDate, - setCurrentPage + setCurrentPage, + userID, + userRole }: { logs: PaginatedResponse; accessToken: string | null; @@ -36,6 +38,8 @@ export function useLogFilterLogic({ pageSize?: number; isCustomDate: boolean; setCurrentPage: (page: number) => void; + userID: string | null; + userRole: string | null; }) { const defaultFilters = useMemo(() => ({ [FILTER_KEYS.TEAM_ID]: "", @@ -71,7 +75,8 @@ export function useLogFilterLogic({ page, pageSize, filters[FILTER_KEYS.USER_ID] || undefined, - filters[FILTER_KEYS.STATUS] || undefined + filters[FILTER_KEYS.STATUS] || undefined, + filters[FILTER_KEYS.MODEL] || undefined ); if (currentTimestamp === lastSearchTimestamp.current && response.data) { @@ -122,6 +127,12 @@ export function useLogFilterLogic({ } ); } + + if (filters[FILTER_KEYS.MODEL]) { + filteredData = filteredData.filter( + log => log.model === filters[FILTER_KEYS.MODEL] + ); + } const newFilteredLogs: PaginatedResponse = { data: filteredData, @@ -159,6 +170,24 @@ export function useLogFilterLogic({ enabled: !!accessToken, }); + const { data: allModels = [] } = useQuery({ + queryKey: ['allModels', accessToken, userID, userRole], + queryFn: async () => { + if (!accessToken || !userID || !userRole) return []; + + const response = await modelAvailableCall( + accessToken, + userID, + userRole, + false, // return_wildcard_routes + null // teamID + ); + + return response.data.map((model: { id: string }) => model.id); + }, + enabled: !!accessToken && !!userID && !!userRole, + }); + // Update filters state const handleFilterChange = (newFilters: Partial) => { setFilters(prev => { @@ -195,6 +224,7 @@ export function useLogFilterLogic({ filteredLogs, allKeyAliases, allTeams, + allModels, handleFilterChange, handleFilterReset, };