(beta ui - spend logs view fixes & Improvements 1) (#8062)

* ui 1 - show correct msg on no logs

* fix dup country col

* backend - allow filtering by team_id and api_key

* fix ui_view_spend_logs

* ui update query params

* working team id and key hash filters

* fix filter ref - don't hold on them as they are

* fix _model_custom_llm_provider_matches_wildcard_pattern

* fix test test_stream_chunk_builder_openai_audio_output_usage - use direct dict comparison
This commit is contained in:
Ishaan Jaff 2025-01-28 20:34:22 -08:00 committed by GitHub
parent 311997ee40
commit ae7b042bc2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 246 additions and 181 deletions

View file

@ -1627,7 +1627,19 @@ async def ui_view_spend_logs( # noqa: PLR0915
), ),
request_id: Optional[str] = fastapi.Query( request_id: Optional[str] = fastapi.Query(
default=None, default=None,
description="request_id to get spend logs for specific request_id. If none passed then pass spend logs for all requests", description="request_id to get spend logs for specific request_id",
),
team_id: Optional[str] = fastapi.Query(
default=None,
description="Filter spend logs by team_id",
),
min_spend: Optional[float] = fastapi.Query(
default=None,
description="Filter logs with spend greater than or equal to this value",
),
max_spend: Optional[float] = fastapi.Query(
default=None,
description="Filter logs with spend less than or equal to this value",
), ),
start_date: Optional[str] = fastapi.Query( start_date: Optional[str] = fastapi.Query(
default=None, default=None,
@ -1674,46 +1686,70 @@ async def ui_view_spend_logs( # noqa: PLR0915
param="None", param="None",
code=status.HTTP_400_BAD_REQUEST, code=status.HTTP_400_BAD_REQUEST,
) )
# Convert the date strings to datetime objects
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d %H:%M:%S")
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d %H:%M:%S")
# Convert to ISO format strings for Prisma try:
start_date_iso = start_date_obj.isoformat() + "Z" # Add Z to indicate UTC # Convert the date strings to datetime objects
end_date_iso = end_date_obj.isoformat() + "Z" # Add Z to indicate UTC start_date_obj = datetime.strptime(start_date, "%Y-%m-%d %H:%M:%S")
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d %H:%M:%S")
# Calculate skip value for pagination # Convert to ISO format strings for Prisma
skip = (page - 1) * page_size start_date_iso = start_date_obj.isoformat() + "Z" # Add Z to indicate UTC
end_date_iso = end_date_obj.isoformat() + "Z" # Add Z to indicate UTC
# Get total count of records # Build where conditions
total_records = await prisma_client.db.litellm_spendlogs.count( where_conditions: dict[str, Any] = {
where={
"startTime": {"gte": start_date_iso, "lte": end_date_iso}, "startTime": {"gte": start_date_iso, "lte": end_date_iso},
} }
)
# Get paginated data if team_id is not None:
data = await prisma_client.db.litellm_spendlogs.find_many( where_conditions["team_id"] = team_id
where={
"startTime": {"gte": start_date_iso, "lte": end_date_iso},
},
order={
"startTime": "desc",
},
skip=skip,
take=page_size,
)
# Calculate total pages if api_key is not None:
total_pages = (total_records + page_size - 1) // page_size where_conditions["api_key"] = api_key
return { if user_id is not None:
"data": data, where_conditions["user"] = user_id
"total": total_records,
"page": page, if request_id is not None:
"page_size": page_size, where_conditions["request_id"] = request_id
"total_pages": total_pages,
} if min_spend is not None or max_spend is not None:
where_conditions["spend"] = {}
if min_spend is not None:
where_conditions["spend"]["gte"] = min_spend
if max_spend is not None:
where_conditions["spend"]["lte"] = max_spend
# Calculate skip value for pagination
skip = (page - 1) * page_size
# Get total count of records
total_records = await prisma_client.db.litellm_spendlogs.count(
where=where_conditions,
)
# Get paginated data
data = await prisma_client.db.litellm_spendlogs.find_many(
where=where_conditions,
order={
"startTime": "desc",
},
skip=skip,
take=page_size,
)
# Calculate total pages
total_pages = (total_records + page_size - 1) // page_size
return {
"data": data,
"total": total_records,
"page": page,
"page_size": page_size,
"total_pages": total_pages,
}
except Exception as e:
verbose_proxy_logger.exception(f"Error in ui_view_spend_logs: {e}")
raise handle_exception_on_proxy(e)
@router.get( @router.get(

View file

@ -721,22 +721,14 @@ def test_stream_chunk_builder_openai_audio_output_usage():
print(f"response usage: {response.usage}") print(f"response usage: {response.usage}")
check_non_streaming_response(response) check_non_streaming_response(response)
print(f"response: {response}") print(f"response: {response}")
for k, v in usage_obj.model_dump(exclude_none=True).items(): # Convert both usage objects to dictionaries for easier comparison
print(k, v) usage_dict = usage_obj.model_dump(exclude_none=True)
response_usage_value = getattr(response.usage, k) # type: ignore response_usage_dict = response.usage.model_dump(exclude_none=True)
print(f"response_usage_value: {response_usage_value}")
print(f"type: {type(response_usage_value)}") # Simple dictionary comparison
if isinstance(response_usage_value, BaseModel): assert (
response_usage_value_dict = response_usage_value.model_dump( usage_dict == response_usage_dict
exclude_none=True ), f"\nExpected: {usage_dict}\nGot: {response_usage_dict}"
)
if isinstance(v, dict):
for key, value in v.items():
assert response_usage_value_dict[key] == value
else:
assert response_usage_value_dict == v
else:
assert response_usage_value == v
def test_stream_chunk_builder_empty_initial_chunk(): def test_stream_chunk_builder_empty_initial_chunk():

View file

@ -1553,12 +1553,14 @@ export const userSpendLogsCall = async (
export const uiSpendLogsCall = async ( export const uiSpendLogsCall = async (
accessToken: String, accessToken: String,
api_key?: string, api_key?: string,
user_id?: string, team_id?: string,
request_id?: string, request_id?: string,
start_date?: string, start_date?: string,
end_date?: string, end_date?: string,
page?: number, page?: number,
page_size?: number, page_size?: number,
min_spend?: number,
max_spend?: number,
) => { ) => {
try { try {
// Construct base URL // Construct base URL
@ -1567,7 +1569,9 @@ export const uiSpendLogsCall = async (
// Add query parameters if they exist // Add query parameters if they exist
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();
if (api_key) queryParams.append('api_key', api_key); if (api_key) queryParams.append('api_key', api_key);
if (user_id) queryParams.append('user_id', user_id); if (team_id) queryParams.append('team_id', team_id);
if (min_spend) queryParams.append('min_spend', min_spend.toString());
if (max_spend) queryParams.append('max_spend', max_spend.toString());
if (request_id) queryParams.append('request_id', request_id); if (request_id) queryParams.append('request_id', request_id);
if (start_date) queryParams.append('start_date', start_date); if (start_date) queryParams.append('start_date', start_date);
if (end_date) queryParams.append('end_date', end_date); if (end_date) queryParams.append('end_date', end_date);
@ -1595,7 +1599,7 @@ export const uiSpendLogsCall = async (
} }
const data = await response.json(); const data = await response.json();
console.log("Spend Logs UI Response:", data); console.log("Spend Logs Response:", data);
return data; return data;
} catch (error) { } catch (error) {
console.error("Failed to fetch spend logs:", error); console.error("Failed to fetch spend logs:", error);

View file

@ -7,6 +7,7 @@ import { CountryCell } from "./country_cell";
export type LogEntry = { export type LogEntry = {
request_id: string; request_id: string;
api_key: string; api_key: string;
team_id: string;
model: string; model: string;
api_base?: string; api_base?: string;
call_type: string; call_type: string;
@ -155,11 +156,6 @@ export const columns: ColumnDef<LogEntry>[] = [
); );
}, },
}, },
{
header: "Country",
accessorKey: "requester_ip_address",
cell: (info: any) => <CountryCell ipAddress={info.getValue()} />,
},
]; ];
const formatMessage = (message: any): string => { const formatMessage = (message: any): string => {

View file

@ -29,14 +29,13 @@ export default function SpendLogsTable({
userID, userID,
}: SpendLogsTableProps) { }: SpendLogsTableProps) {
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [keyNameFilter, setKeyNameFilter] = useState("");
const [teamNameFilter, setTeamNameFilter] = useState("");
const [showFilters, setShowFilters] = useState(false); const [showFilters, setShowFilters] = useState(false);
const [showColumnDropdown, setShowColumnDropdown] = useState(false); const [showColumnDropdown, setShowColumnDropdown] = useState(false);
const [selectedColumn, setSelectedColumn] = useState("Key Name");
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(50); const [pageSize] = useState(50);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const filtersRef = useRef<HTMLDivElement>(null);
const quickSelectRef = useRef<HTMLDivElement>(null);
// New state variables for Start and End Time // New state variables for Start and End Time
const [startTime, setStartTime] = useState<string>( const [startTime, setStartTime] = useState<string>(
@ -49,6 +48,11 @@ export default function SpendLogsTable({
// Add these new state variables at the top with other useState declarations // Add these new state variables at the top with other useState declarations
const [isCustomDate, setIsCustomDate] = useState(false); const [isCustomDate, setIsCustomDate] = useState(false);
const [quickSelectOpen, setQuickSelectOpen] = useState(false); const [quickSelectOpen, setQuickSelectOpen] = useState(false);
const [tempTeamId, setTempTeamId] = useState("");
const [tempKeyHash, setTempKeyHash] = useState("");
const [selectedTeamId, setSelectedTeamId] = useState("");
const [selectedKeyHash, setSelectedKeyHash] = useState("");
const [selectedFilter, setSelectedFilter] = useState("Team ID");
// Close dropdown when clicking outside // Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
@ -59,6 +63,18 @@ export default function SpendLogsTable({
) { ) {
setShowColumnDropdown(false); setShowColumnDropdown(false);
} }
if (
filtersRef.current &&
!filtersRef.current.contains(event.target as Node)
) {
setShowFilters(false);
}
if (
quickSelectRef.current &&
!quickSelectRef.current.contains(event.target as Node)
) {
setQuickSelectOpen(false);
}
} }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
@ -67,12 +83,19 @@ export default function SpendLogsTable({
}, []); }, []);
const logs = useQuery<PaginatedResponse>({ const logs = useQuery<PaginatedResponse>({
queryKey: ["logs", "table", currentPage, pageSize, startTime, endTime], queryKey: [
"logs",
"table",
currentPage,
pageSize,
startTime,
endTime,
selectedTeamId,
selectedKeyHash,
],
queryFn: async () => { queryFn: async () => {
if (!accessToken || !token || !userRole || !userID) { if (!accessToken || !token || !userRole || !userID) {
console.log( console.log("Missing required auth parameters");
"got None values for one of accessToken, token, userRole, userID",
);
return { return {
data: [], data: [],
total: 0, total: 0,
@ -85,20 +108,17 @@ export default function SpendLogsTable({
const formattedStartTime = moment(startTime).format("YYYY-MM-DD HH:mm:ss"); const formattedStartTime = moment(startTime).format("YYYY-MM-DD HH:mm:ss");
const formattedEndTime = moment(endTime).format("YYYY-MM-DD HH:mm:ss"); const formattedEndTime = moment(endTime).format("YYYY-MM-DD HH:mm:ss");
const data = await uiSpendLogsCall( return await uiSpendLogsCall(
accessToken, accessToken,
token, selectedKeyHash || undefined,
userRole, selectedTeamId || undefined,
userID, undefined, // This parameter might be setting a default min_spend
formattedStartTime, formattedStartTime,
formattedEndTime, formattedEndTime,
currentPage, currentPage,
pageSize, pageSize
); );
return data;
}, },
// Refetch when startTime or endTime changes
enabled: !!accessToken && !!token && !!userRole && !!userID, enabled: !!accessToken && !!token && !!userRole && !!userID,
}); });
@ -116,21 +136,12 @@ export default function SpendLogsTable({
log.request_id.includes(searchTerm) || log.request_id.includes(searchTerm) ||
log.model.includes(searchTerm) || log.model.includes(searchTerm) ||
(log.user && log.user.includes(searchTerm)); (log.user && log.user.includes(searchTerm));
const matchesKeyName =
!keyNameFilter || // No need for additional filtering since we're now handling this in the API call
(log.metadata?.user_api_key_alias && return matchesSearch;
log.metadata.user_api_key_alias
.toLowerCase()
.includes(keyNameFilter.toLowerCase()));
const matchesTeamName =
!teamNameFilter ||
(log.metadata?.user_api_key_team_alias &&
log.metadata.user_api_key_team_alias
.toLowerCase()
.includes(teamNameFilter.toLowerCase()));
return matchesSearch && matchesKeyName && matchesTeamName;
}) || []; }) || [];
return ( return (
<div className="w-full"> <div className="w-full">
<h1 className="text-xl font-semibold mb-4">Traces</h1> <h1 className="text-xl font-semibold mb-4">Traces</h1>
@ -160,7 +171,7 @@ export default function SpendLogsTable({
/> />
</svg> </svg>
</div> </div>
<div className="relative" ref={dropdownRef}> <div className="relative" ref={filtersRef}>
<button <button
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2" className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
@ -183,100 +194,123 @@ export default function SpendLogsTable({
{showFilters && ( {showFilters && (
<div className="absolute left-0 mt-2 w-[500px] bg-white rounded-lg shadow-lg border p-4 z-50"> <div className="absolute left-0 mt-2 w-[500px] bg-white rounded-lg shadow-lg border p-4 z-50">
<div className="flex items-center gap-2"> <div className="flex flex-col gap-4">
<span className="text-sm font-medium">Where</span> <div className="flex items-center gap-2">
<div className="relative"> <span className="text-sm font-medium">Where</span>
<button <div className="relative">
onClick={() => <button
setShowColumnDropdown(!showColumnDropdown) onClick={() => setShowColumnDropdown(!showColumnDropdown)}
} className="px-3 py-1.5 border rounded-md bg-white text-sm min-w-[160px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-left flex justify-between items-center"
className="px-3 py-1.5 border rounded-md bg-white text-sm min-w-[160px] focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-left flex justify-between items-center"
>
{selectedColumn}
<svg
className="h-4 w-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path {selectedFilter}
strokeLinecap="round" <svg
strokeLinejoin="round" className="h-4 w-4 text-gray-500"
strokeWidth={2} fill="none"
d="M19 9l-7 7-7-7" stroke="currentColor"
/> viewBox="0 0 24 24"
</svg> >
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{showColumnDropdown && (
<div className="absolute left-0 mt-1 w-[160px] bg-white border rounded-md shadow-lg z-50">
{["Team ID", "Key Hash"].map((option) => (
<button
key={option}
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-2 ${
selectedFilter === option
? "bg-blue-50 text-blue-600"
: ""
}`}
onClick={() => {
setSelectedFilter(option);
setShowColumnDropdown(false);
if (option === "Team ID") {
setTempKeyHash("");
} else {
setTempTeamId("");
}
}}
>
{selectedFilter === option && (
<svg
className="h-4 w-4 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
{option}
</button>
))}
</div>
)}
</div>
<input
type="text"
placeholder="Enter value..."
className="px-3 py-1.5 border rounded-md text-sm flex-1 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={selectedFilter === "Team ID" ? tempTeamId : tempKeyHash}
onChange={(e) => {
if (selectedFilter === "Team ID") {
setTempTeamId(e.target.value);
} else {
setTempKeyHash(e.target.value);
}
}}
/>
<button
className="p-1 hover:bg-gray-100 rounded-md"
onClick={() => {
setTempTeamId("");
setTempKeyHash("");
}}
>
<span className="text-gray-500">×</span>
</button>
</div>
<div className="flex justify-end gap-2">
<button
className="px-3 py-1.5 text-sm border rounded-md hover:bg-gray-50"
onClick={() => {
setTempTeamId("");
setTempKeyHash("");
setShowFilters(false);
}}
>
Cancel
</button>
<button
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700"
onClick={() => {
setSelectedTeamId(tempTeamId);
setSelectedKeyHash(tempKeyHash);
setCurrentPage(1); // Reset to first page when applying new filters
setShowFilters(false);
}}
>
Apply Filters
</button> </button>
{showColumnDropdown && (
<div className="absolute left-0 mt-1 w-[160px] bg-white border rounded-md shadow-lg z-50">
{["Key Name", "Team Name"].map((option) => (
<button
key={option}
className={`w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-2 ${
selectedColumn === option
? "bg-blue-50 text-blue-600"
: ""
}`}
onClick={() => {
setSelectedColumn(option);
setShowColumnDropdown(false);
if (option === "Key Name") {
setTeamNameFilter("");
} else {
setKeyNameFilter("");
}
}}
>
{selectedColumn === option && (
<svg
className="h-4 w-4 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
{option}
</button>
))}
</div>
)}
</div> </div>
<input
type="text"
placeholder="Enter value..."
className="px-3 py-1.5 border rounded-md text-sm flex-1 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
value={keyNameFilter || teamNameFilter}
onChange={(e) => {
if (selectedColumn === "Key Name") {
setKeyNameFilter(e.target.value);
} else {
setTeamNameFilter(e.target.value);
}
}}
/>
<button
className="p-1 hover:bg-gray-100 rounded-md"
onClick={() => {
setKeyNameFilter("");
setTeamNameFilter("");
setShowFilters(false);
}}
>
<span className="text-gray-500">×</span>
</button>
</div> </div>
</div> </div>
)} )}
</div> </div>
<div className="relative"> <div className="relative" ref={quickSelectRef}>
<button <button
onClick={() => setQuickSelectOpen(!quickSelectOpen)} onClick={() => setQuickSelectOpen(!quickSelectOpen)}
className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2" className="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"

View file

@ -22,6 +22,7 @@ interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
renderSubComponent: (props: { row: Row<TData> }) => React.ReactElement; renderSubComponent: (props: { row: Row<TData> }) => React.ReactElement;
getRowCanExpand: (row: Row<TData>) => boolean; getRowCanExpand: (row: Row<TData>) => boolean;
isLoading?: boolean;
} }
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
@ -29,6 +30,7 @@ export function DataTable<TData, TValue>({
columns, columns,
getRowCanExpand, getRowCanExpand,
renderSubComponent, renderSubComponent,
isLoading = false,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const table = useReactTable({ const table = useReactTable({
data, data,
@ -60,7 +62,15 @@ export function DataTable<TData, TValue>({
))} ))}
</TableHead> </TableHead>
<TableBody> <TableBody>
{table.getRowModel().rows.length > 0 ? ( {isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
<div className="p-8 text-center text-gray-500">
<p>🚅 Loading logs...</p>
</div>
</TableCell>
</TableRow>
) : table.getRowModel().rows.length > 0 ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<Fragment key={row.id}> <Fragment key={row.id}>
<TableRow> <TableRow>
@ -87,14 +97,7 @@ export function DataTable<TData, TValue>({
<TableRow> <TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center"> <TableCell colSpan={columns.length} className="h-24 text-center">
<div className="p-8 text-center text-gray-500"> <div className="p-8 text-center text-gray-500">
<p>No SpendLogs messages available.</p> <p>No logs found</p>
<p className="text-sm mt-2">
To enable this, set{" "}
<code>
`general_settings.store_prompts_in_spend_logs: true`
</code>{" "}
in your config.yaml
</p>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>