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>
This commit is contained in:
Ishaan Jaff 2025-05-16 10:04:02 -07:00 committed by GitHub
parent d5d5166777
commit f335bb91dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 284 additions and 14 deletions

View file

@ -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}}

View file

@ -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):

View file

@ -88,6 +88,7 @@ const FilterComponent: React.FC<FilterComponentProps> = ({
"Key Alias",
"User ID",
"Key Hash",
"Model"
];
return (

View file

@ -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) {

View file

@ -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<KeyResponse | null>(null);
const [selectedKeyIdInfoView, setSelectedKeyIdInfoView] = useState<string | null>(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
}));
}
}
]

View file

@ -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<LogFilterState>(() => ({
[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<string[], Error>({
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<LogFilterState>) => {
setFilters(prev => {
@ -195,6 +224,7 @@ export function useLogFilterLogic({
filteredLogs,
allKeyAliases,
allTeams,
allModels,
handleFilterChange,
handleFilterReset,
};