feat(proxy_server.py): show admin global spend as time series data

This commit is contained in:
Krrish Dholakia 2024-02-09 16:31:35 -08:00
parent e39ce9b119
commit 43da22ae13
8 changed files with 422 additions and 98 deletions

View file

@ -3015,16 +3015,7 @@ async def info_key_fn(
tags=["budget & spend Tracking"], tags=["budget & spend Tracking"],
dependencies=[Depends(user_api_key_auth)], dependencies=[Depends(user_api_key_auth)],
) )
async def spend_key_fn( async def spend_key_fn():
start_date: Optional[str] = fastapi.Query(
default=None,
description="Time from which to start viewing key spend",
),
end_date: Optional[str] = fastapi.Query(
default=None,
description="Time till which to view key spend",
),
):
""" """
View all keys created, ordered by spend View all keys created, ordered by spend
@ -3041,40 +3032,7 @@ async def spend_key_fn(
f"Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys" f"Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys"
) )
if ( key_info = await prisma_client.get_data(table_name="key", query_type="find_all")
start_date is not None
and isinstance(start_date, str)
and end_date is not None
and isinstance(end_date, str)
):
# Convert the date strings to datetime objects
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d")
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
# SQL query
response = await prisma_client.db.litellm_spendlogs.group_by(
by=["api_key", "startTime"],
where={
"startTime": {
"gte": start_date_obj, # Greater than or equal to Start Date
"lte": end_date_obj, # Less than or equal to End Date
}
},
sum={
"spend": True,
},
)
# TODO: Execute SQL query and return the results
return {
"message": "This is your SQL query",
"response": response,
}
else:
key_info = await prisma_client.get_data(
table_name="key", query_type="find_all"
)
return key_info return key_info
except Exception as e: except Exception as e:
@ -3157,6 +3115,14 @@ async def view_spend_logs(
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. If none passed then pass spend logs for all requests",
), ),
start_date: Optional[str] = fastapi.Query(
default=None,
description="Time from which to start viewing key spend",
),
end_date: Optional[str] = fastapi.Query(
default=None,
description="Time till which to view key spend",
),
): ):
""" """
View all spend logs, if request_id is provided, only logs for that request_id will be returned View all spend logs, if request_id is provided, only logs for that request_id will be returned
@ -3193,7 +3159,76 @@ async def view_spend_logs(
f"Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys" f"Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys"
) )
spend_logs = [] spend_logs = []
if (
start_date is not None
and isinstance(start_date, str)
and end_date is not None
and isinstance(end_date, str)
):
# Convert the date strings to datetime objects
start_date_obj = datetime.strptime(start_date, "%Y-%m-%d")
end_date_obj = datetime.strptime(end_date, "%Y-%m-%d")
filter_query = {
"startTime": {
"gte": start_date_obj, # Greater than or equal to Start Date
"lte": end_date_obj, # Less than or equal to End Date
}
}
if api_key is not None and isinstance(api_key, str): if api_key is not None and isinstance(api_key, str):
filter_query["api_key"] = api_key # type: ignore
elif request_id is not None and isinstance(request_id, str):
filter_query["request_id"] = request_id # type: ignore
elif user_id is not None and isinstance(user_id, str):
filter_query["user"] = user_id # type: ignore
# SQL query
response = await prisma_client.db.litellm_spendlogs.group_by(
by=["startTime"],
where=filter_query, # type: ignore
sum={
"spend": True,
},
)
if (
isinstance(response, list)
and len(response) > 0
and isinstance(response[0], dict)
):
result: dict = {}
for record in response:
dt_object = datetime.strptime(
str(record["startTime"]), "%Y-%m-%dT%H:%M:%S.%fZ"
) # type: ignore
date = dt_object.date()
if date not in result:
result[date] = {}
result[date]["spend"] = (
result[date].get("spend", 0) + record["_sum"]["spend"]
)
return_list = []
final_date = None
for k, v in sorted(result.items()):
return_list.append({**v, "startTime": k})
final_date = k
end_date_date = end_date_obj.date()
if final_date is not None and final_date < end_date_date:
current_date = final_date + timedelta(days=1)
while current_date <= end_date_date:
# Represent current_date as string because original response has it this way
return_list.append(
{"startTime": current_date, "spend": 0}
) # If no data, will stay as zero
current_date += timedelta(days=1) # Move on to the next day
return return_list
return response
elif api_key is not None and isinstance(api_key, str):
if api_key.startswith("sk-"): if api_key.startswith("sk-"):
hashed_token = prisma_client.hash_token(token=api_key) hashed_token = prisma_client.hash_token(token=api_key)
else: else:
@ -3478,12 +3513,22 @@ async def login(request: Request):
if secrets.compare_digest(username, ui_username) and secrets.compare_digest( if secrets.compare_digest(username, ui_username) and secrets.compare_digest(
password, ui_password password, ui_password
): ):
user_role = "app_owner"
user_id = username user_id = username
# User is Authe'd in - generate key for the UI to access Proxy key_user_id = user_id
if (
os.getenv("PROXY_ADMIN_ID", None) is not None
and os.environ["PROXY_ADMIN_ID"] == user_id
) or user_id == "admin":
# checks if user is admin
user_role = "app_admin"
key_user_id = os.getenv("PROXY_ADMIN_ID", "default_user_id")
# Admin is Authe'd in - generate key for the UI to access Proxy
if os.getenv("DATABASE_URL") is not None: if os.getenv("DATABASE_URL") is not None:
response = await generate_key_helper_fn( response = await generate_key_helper_fn(
**{"duration": "1hr", "key_max_budget": 0, "models": [], "aliases": {}, "config": {}, "spend": 0, "user_id": user_id, "team_id": "litellm-dashboard"} # type: ignore **{"duration": "1hr", "key_max_budget": 0, "models": [], "aliases": {}, "config": {}, "spend": 0, "user_id": key_user_id, "team_id": "litellm-dashboard"} # type: ignore
) )
else: else:
response = { response = {
@ -3492,18 +3537,9 @@ async def login(request: Request):
} }
key = response["token"] # type: ignore key = response["token"] # type: ignore
user_id = response["user_id"] # type: ignore
litellm_dashboard_ui = "/ui/" litellm_dashboard_ui = "/ui/"
user_role = "app_owner"
if (
os.getenv("PROXY_ADMIN_ID", None) is not None
and os.environ["PROXY_ADMIN_ID"] == user_id
):
# checks if user is admin
user_role = "app_admin"
import jwt import jwt
jwt_token = jwt.encode( jwt_token = jwt.encode(

View file

@ -240,7 +240,10 @@ class ProxyLogging:
else: else:
user_info = str(user_info) user_info = str(user_info)
# percent of max_budget left to spend # percent of max_budget left to spend
if user_max_budget > 0:
percent_left = (user_max_budget - user_current_spend) / user_max_budget percent_left = (user_max_budget - user_current_spend) / user_max_budget
else:
percent_left = 0
verbose_proxy_logger.debug( verbose_proxy_logger.debug(
f"Budget Alerts: Percent left: {percent_left} for {user_info}" f"Budget Alerts: Percent left: {percent_left} for {user_info}"
) )

View file

@ -1,11 +1,92 @@
import React, { Suspense } from "react"; "use client";
import React, { Suspense, useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import Navbar from "../components/navbar"; import Navbar from "../components/navbar";
import UserDashboard from "../components/user_dashboard"; import UserDashboard from "../components/user_dashboard";
import Sidebar from "../components/leftnav";
import Usage from "../components/usage";
import { jwtDecode } from "jwt-decode";
const CreateKeyPage = () => { const CreateKeyPage = () => {
const [userRole, setUserRole] = useState<null | string>(null);
const [userEmail, setUserEmail] = useState<null | string>(null);
const [page, setPage] = useState("api-keys");
const searchParams = useSearchParams();
const userID = searchParams.get("userID");
const token = searchParams.get("token");
const [accessToken, setAccessToken] = useState<string | null>(null);
useEffect(() => {
if (token) {
const decoded = jwtDecode(token) as { [key: string]: any };
if (decoded) {
// cast decoded to dictionary
console.log("Decoded token:", decoded);
console.log("Decoded key:", decoded.key);
// set accessToken
setAccessToken(decoded.key);
// check if userRole is defined
if (decoded.user_role) {
const formattedUserRole = formatUserRole(decoded.user_role);
console.log("Decoded user_role:", formattedUserRole);
setUserRole(formattedUserRole);
} else {
console.log("User role not defined");
}
if (decoded.user_email) {
setUserEmail(decoded.user_email);
} else {
console.log(`User Email is not set ${decoded}`);
}
}
}
}, [token]);
function formatUserRole(userRole: string) {
if (!userRole) {
return "Undefined Role";
}
console.log(`Received user role: ${userRole}`);
switch (userRole.toLowerCase()) {
case "app_owner":
return "App Owner";
case "demo_app_owner":
return "App Owner";
case "app_admin":
return "Admin";
case "app_user":
return "App User";
default:
return "Unknown Role";
}
}
return ( return (
<Suspense fallback={<div>Loading...</div>}> <Suspense fallback={<div>Loading...</div>}>
<div className="flex min-h-screen flex-col "> <div className="flex flex-col min-h-screen">
<UserDashboard /> <Navbar userID={userID} userRole={userRole} userEmail={userEmail} />
<div className="flex flex-1 overflow-auto">
<Sidebar setPage={setPage} />
{page == "api-keys" ? (
<UserDashboard
userID={userID}
userRole={userRole}
setUserRole={setUserRole}
userEmail={userEmail}
setUserEmail={setUserEmail}
/>
) : (
<Usage
userID={userID}
userRole={userRole}
token={token}
accessToken={accessToken}
/>
)}
</div>
</div> </div>
</Suspense> </Suspense>
); );

View file

@ -0,0 +1,32 @@
import { Layout, Menu } from "antd";
import Link from "next/link";
const { Sider } = Layout;
// Define the props type
interface SidebarProps {
setPage: React.Dispatch<React.SetStateAction<string>>;
}
const Sidebar: React.FC<SidebarProps> = ({ setPage }) => {
return (
<Layout style={{ minHeight: "100vh", maxWidth: "120px" }}>
<Sider width={120}>
<Menu
mode="inline"
defaultSelectedKeys={["1"]}
style={{ height: "100%", borderRight: 0 }}
>
<Menu.Item key="1" onClick={() => setPage("api-keys")}>
API Keys
</Menu.Item>
<Menu.Item key="2" onClick={() => setPage("usage")}>
Usage
</Menu.Item>
</Menu>
</Sider>
</Layout>
);
};
export default Sidebar;

View file

@ -162,3 +162,40 @@ export const keySpendLogsCall = async (accessToken: String, token: String) => {
throw error; throw error;
} }
}; };
export const userSpendLogsCall = async (
accessToken: String,
token: String,
userRole: String,
userID: String,
startTime: String,
endTime: String
) => {
try {
let url = proxyBaseUrl ? `${proxyBaseUrl}/spend/logs` : `/spend/logs`;
if (userRole == "App Owner") {
url = `${url}/?user_id=${userID}&start_date=${startTime}&end_date=${endTime}`;
} else {
url = `${url}/?start_date=${startTime}&end_date=${endTime}`;
}
const response = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
message.error(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
console.log(data);
return data;
} catch (error) {
console.error("Failed to create key:", error);
throw error;
}
};

View file

@ -0,0 +1,114 @@
import { BarChart, Card, Title } from "@tremor/react";
import React, { useState, useEffect } from "react";
import { Grid, Col, Text } from "@tremor/react";
import { userSpendLogsCall } from "./networking";
import { AreaChart, Flex, Switch, Subtitle } from "@tremor/react";
interface UsagePageProps {
accessToken: string | null;
token: string | null;
userRole: string | null;
userID: string | null;
}
type DataType = {
api_key: string;
startTime: string;
_sum: {
spend: number;
};
};
const UsagePage: React.FC<UsagePageProps> = ({
accessToken,
token,
userRole,
userID,
}) => {
const currentDate = new Date();
const [keySpendData, setKeySpendData] = useState<any[]>([]);
const firstDay = new Date(
currentDate.getFullYear(),
currentDate.getMonth(),
1
);
const lastDay = new Date(
currentDate.getFullYear(),
currentDate.getMonth() + 1,
0
);
let startTime = formatDate(firstDay);
let endTime = formatDate(lastDay);
function formatDate(date: Date) {
const year = date.getFullYear();
let month = date.getMonth() + 1; // JS month index starts from 0
let day = date.getDate();
// Pad with 0 if month or day is less than 10
const monthStr = month < 10 ? "0" + month : month;
const dayStr = day < 10 ? "0" + day : day;
return `${year}-${monthStr}-${dayStr}`;
}
console.log(`Start date is ${startTime}`);
console.log(`End date is ${endTime}`);
const valueFormatter = (number: number) =>
`$ ${new Intl.NumberFormat("us").format(number).toString()}`;
useEffect(() => {
if (accessToken && token && userRole && userID) {
const cachedKeySpendData = localStorage.getItem("keySpendData");
if (cachedKeySpendData) {
setKeySpendData(JSON.parse(cachedKeySpendData));
} else {
const fetchData = async () => {
try {
const response = await userSpendLogsCall(
accessToken,
token,
userRole,
userID,
startTime,
endTime
);
setKeySpendData(response);
localStorage.setItem("keySpendData", JSON.stringify(response));
} catch (error) {
console.error("There was an error fetching the data", error);
// Optionally, update your UI to reflect the error state here as well
}
};
fetchData();
}
}
}, [accessToken, token, userRole, userID]);
return (
<div style={{ width: "100%" }}>
<Grid numItems={1} className="gap-0 p-10 h-[75vh] w-full">
<Col numColSpan={1}>
<Card>
<Title>Monthly Spend</Title>
<BarChart
data={keySpendData}
index="startTime"
categories={["spend"]}
colors={["blue"]}
valueFormatter={valueFormatter}
yAxisWidth={100}
tickGap={5}
/>
</Card>
</Col>
</Grid>
</div>
);
};
export default UsagePage;

View file

@ -20,7 +20,21 @@ type UserSpendData = {
max_budget?: number | null; max_budget?: number | null;
}; };
const UserDashboard = () => { interface UserDashboardProps {
userID: string | null;
userRole: string | null;
userEmail: string | null;
setUserRole: React.Dispatch<React.SetStateAction<string | null>>;
setUserEmail: React.Dispatch<React.SetStateAction<string | null>>;
}
const UserDashboard: React.FC<UserDashboardProps> = ({
userID,
userRole,
setUserRole,
userEmail,
setUserEmail,
}) => {
const [data, setData] = useState<null | any[]>(null); // Keep the initialization of state here const [data, setData] = useState<null | any[]>(null); // Keep the initialization of state here
const [userSpendData, setUserSpendData] = useState<UserSpendData | null>( const [userSpendData, setUserSpendData] = useState<UserSpendData | null>(
null null
@ -28,13 +42,10 @@ const UserDashboard = () => {
// Assuming useSearchParams() hook exists and works in your setup // Assuming useSearchParams() hook exists and works in your setup
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const userID = searchParams.get("userID");
const viewSpend = searchParams.get("viewSpend"); const viewSpend = searchParams.get("viewSpend");
const token = searchParams.get("token"); const token = searchParams.get("token");
const [accessToken, setAccessToken] = useState<string | null>(null); const [accessToken, setAccessToken] = useState<string | null>(null);
const [userRole, setUserRole] = useState<string | null>(null);
const [userEmail, setUserEmail] = useState<string | null>(null);
function formatUserRole(userRole: string) { function formatUserRole(userRole: string) {
if (!userRole) { if (!userRole) {
@ -84,11 +95,22 @@ const UserDashboard = () => {
} }
} }
if (userID && accessToken && userRole && !data) { if (userID && accessToken && userRole && !data) {
const cachedData = localStorage.getItem("userData");
const cachedSpendData = localStorage.getItem("userSpendData");
if (cachedData && cachedSpendData) {
setData(JSON.parse(cachedData));
setUserSpendData(JSON.parse(cachedSpendData));
} else {
const fetchData = async () => { const fetchData = async () => {
try { try {
const response = await userInfoCall(accessToken, userID, userRole); const response = await userInfoCall(accessToken, userID, userRole);
setUserSpendData(response["user_info"]); setUserSpendData(response["user_info"]);
setData(response["keys"]); // Assuming this is the correct path to your data setData(response["keys"]); // Assuming this is the correct path to your data
localStorage.setItem("userData", JSON.stringify(response["keys"]));
localStorage.setItem(
"userSpendData",
JSON.stringify(response["user_info"])
);
} catch (error) { } catch (error) {
console.error("There was an error fetching the data", error); console.error("There was an error fetching the data", error);
// Optionally, update your UI to reflect the error state here as well // Optionally, update your UI to reflect the error state here as well
@ -96,6 +118,7 @@ const UserDashboard = () => {
}; };
fetchData(); fetchData();
} }
}
}, [userID, token, accessToken, data]); }, [userID, token, accessToken, data]);
if (userID == null || token == null) { if (userID == null || token == null) {
@ -117,7 +140,6 @@ const UserDashboard = () => {
return ( return (
<div> <div>
<Navbar userID={userID} userRole={userRole} userEmail={userEmail} />
<Grid numItems={1} className="gap-0 p-10 h-[75vh] w-full"> <Grid numItems={1} className="gap-0 p-10 h-[75vh] w-full">
<Col numColSpan={1}> <Col numColSpan={1}>
<ViewUserSpend userID={userID} userSpendData={userSpendData} /> <ViewUserSpend userID={userID} userSpendData={userSpendData} />

View file

@ -72,21 +72,6 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
setKeyToDelete(null); setKeyToDelete(null);
}; };
// const handleDelete = async (token: String) => {
// if (data == null) {
// return;
// }
// try {
// await keyDeleteCall(accessToken, token);
// // Successfully completed the deletion. Update the state to trigger a rerender.
// const filteredData = data.filter((item) => item.token !== token);
// setData(filteredData);
// } catch (error) {
// console.error("Error deleting the key:", error);
// // Handle any error situations, such as displaying an error message to the user.
// }
// };
if (data == null) { if (data == null) {
return; return;
} }
@ -147,7 +132,11 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
<Text>{JSON.stringify(item.models)}</Text> <Text>{JSON.stringify(item.models)}</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Text>TPM Limit: {item.tpm_limit? item.tpm_limit : "Unlimited"} <br></br> RPM Limit: {item.rpm_limit? item.rpm_limit : "Unlimited"}</Text> <Text>
TPM Limit: {item.tpm_limit ? item.tpm_limit : "Unlimited"}{" "}
<br></br> RPM Limit:{" "}
{item.rpm_limit ? item.rpm_limit : "Unlimited"}
</Text>
</TableCell> </TableCell>
<TableCell> <TableCell>
{item.expires != null ? ( {item.expires != null ? (
@ -180,12 +169,18 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
{isDeleteModalOpen && ( {isDeleteModalOpen && (
<div className="fixed z-10 inset-0 overflow-y-auto"> <div className="fixed z-10 inset-0 overflow-y-auto">
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"> <div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 transition-opacity" aria-hidden="true"> <div
className="fixed inset-0 transition-opacity"
aria-hidden="true"
>
<div className="absolute inset-0 bg-gray-500 opacity-75"></div> <div className="absolute inset-0 bg-gray-500 opacity-75"></div>
</div> </div>
{/* Modal Panel */} {/* Modal Panel */}
<span className="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true"> <span
className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>
&#8203; &#8203;
</span> </span>
@ -194,9 +189,13 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4"> <div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="sm:flex sm:items-start"> <div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left"> <div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 className="text-lg leading-6 font-medium text-gray-900">Delete Key</h3> <h3 className="text-lg leading-6 font-medium text-gray-900">
Delete Key
</h3>
<div className="mt-2"> <div className="mt-2">
<p className="text-sm text-gray-500">Are you sure you want to delete this key ?</p> <p className="text-sm text-gray-500">
Are you sure you want to delete this key ?
</p>
</div> </div>
</div> </div>
</div> </div>