From 43da22ae135075126cb67e40c101788ee6eeb94f Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 9 Feb 2024 16:31:35 -0800 Subject: [PATCH 1/4] feat(proxy_server.py): show admin global spend as time series data --- litellm/proxy/proxy_server.py | 150 +++++++++++------- litellm/proxy/utils.py | 5 +- ui/litellm-dashboard/src/app/page.tsx | 89 ++++++++++- .../src/components/leftnav.tsx | 32 ++++ .../src/components/networking.tsx | 37 +++++ ui/litellm-dashboard/src/components/usage.tsx | 114 +++++++++++++ .../src/components/user_dashboard.tsx | 54 +++++-- .../src/components/view_key_table.tsx | 39 +++-- 8 files changed, 422 insertions(+), 98 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/leftnav.tsx create mode 100644 ui/litellm-dashboard/src/components/usage.tsx diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a2d03486da0f..f719ec0cc9db 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -3015,16 +3015,7 @@ async def info_key_fn( tags=["budget & spend Tracking"], dependencies=[Depends(user_api_key_auth)], ) -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", - ), -): +async def spend_key_fn(): """ View all keys created, ordered by spend @@ -3041,41 +3032,8 @@ 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" ) - 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") - - # 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 + key_info = await prisma_client.get_data(table_name="key", query_type="find_all") + return key_info except Exception as e: raise HTTPException( @@ -3157,6 +3115,14 @@ async def view_spend_logs( default=None, 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 @@ -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" ) spend_logs = [] - if api_key is not None and isinstance(api_key, str): + 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): + 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-"): hashed_token = prisma_client.hash_token(token=api_key) else: @@ -3478,12 +3513,22 @@ async def login(request: Request): if secrets.compare_digest(username, ui_username) and secrets.compare_digest( password, ui_password ): + user_role = "app_owner" 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: 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: response = { @@ -3492,18 +3537,9 @@ async def login(request: Request): } key = response["token"] # type: ignore - user_id = response["user_id"] # type: ignore 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 jwt_token = jwt.encode( diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 919799ca5867..35b6472577a2 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -240,7 +240,10 @@ async def budget_alerts( else: user_info = str(user_info) # percent of max_budget left to spend - percent_left = (user_max_budget - user_current_spend) / user_max_budget + if user_max_budget > 0: + percent_left = (user_max_budget - user_current_spend) / user_max_budget + else: + percent_left = 0 verbose_proxy_logger.debug( f"Budget Alerts: Percent left: {percent_left} for {user_info}" ) diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index f806050c6be6..d67915e4da9b 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -1,12 +1,93 @@ -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 UserDashboard from "../components/user_dashboard"; +import Sidebar from "../components/leftnav"; +import Usage from "../components/usage"; +import { jwtDecode } from "jwt-decode"; + const CreateKeyPage = () => { + const [userRole, setUserRole] = useState(null); + const [userEmail, setUserEmail] = useState(null); + const [page, setPage] = useState("api-keys"); + const searchParams = useSearchParams(); + const userID = searchParams.get("userID"); + const token = searchParams.get("token"); + const [accessToken, setAccessToken] = useState(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 ( Loading...}> -
- -
+
+ +
+ + {page == "api-keys" ? ( + + ) : ( + + )} +
+
); }; diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx new file mode 100644 index 000000000000..d19d199f53b7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -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>; +} + +const Sidebar: React.FC = ({ setPage }) => { + return ( + + + + setPage("api-keys")}> + API Keys + + setPage("usage")}> + Usage + + + + + ); +}; + +export default Sidebar; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 12eea2dd8ecc..477da0047903 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -162,3 +162,40 @@ export const keySpendLogsCall = async (accessToken: String, token: String) => { 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; + } +}; diff --git a/ui/litellm-dashboard/src/components/usage.tsx b/ui/litellm-dashboard/src/components/usage.tsx new file mode 100644 index 000000000000..5908ef10db18 --- /dev/null +++ b/ui/litellm-dashboard/src/components/usage.tsx @@ -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 = ({ + accessToken, + token, + userRole, + userID, +}) => { + const currentDate = new Date(); + const [keySpendData, setKeySpendData] = useState([]); + + 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 ( +
+ + + + Monthly Spend + + + + +
+ ); +}; + +export default UsagePage; diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index b1a06939b166..ae39602059af 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -20,7 +20,21 @@ type UserSpendData = { max_budget?: number | null; }; -const UserDashboard = () => { +interface UserDashboardProps { + userID: string | null; + userRole: string | null; + userEmail: string | null; + setUserRole: React.Dispatch>; + setUserEmail: React.Dispatch>; +} + +const UserDashboard: React.FC = ({ + userID, + userRole, + setUserRole, + userEmail, + setUserEmail, +}) => { const [data, setData] = useState(null); // Keep the initialization of state here const [userSpendData, setUserSpendData] = useState( null @@ -28,13 +42,10 @@ const UserDashboard = () => { // Assuming useSearchParams() hook exists and works in your setup const searchParams = useSearchParams(); - const userID = searchParams.get("userID"); const viewSpend = searchParams.get("viewSpend"); const token = searchParams.get("token"); const [accessToken, setAccessToken] = useState(null); - const [userRole, setUserRole] = useState(null); - const [userEmail, setUserEmail] = useState(null); function formatUserRole(userRole: string) { if (!userRole) { @@ -84,17 +95,29 @@ const UserDashboard = () => { } } if (userID && accessToken && userRole && !data) { - const fetchData = async () => { - try { - const response = await userInfoCall(accessToken, userID, userRole); - setUserSpendData(response["user_info"]); - setData(response["keys"]); // Assuming this is the correct path to your data - } 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(); + 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 () => { + try { + const response = await userInfoCall(accessToken, userID, userRole); + setUserSpendData(response["user_info"]); + 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) { + console.error("There was an error fetching the data", error); + // Optionally, update your UI to reflect the error state here as well + } + }; + fetchData(); + } } }, [userID, token, accessToken, data]); @@ -117,7 +140,6 @@ const UserDashboard = () => { return (
- diff --git a/ui/litellm-dashboard/src/components/view_key_table.tsx b/ui/litellm-dashboard/src/components/view_key_table.tsx index d9fbc7cb9135..12bf44f0756f 100644 --- a/ui/litellm-dashboard/src/components/view_key_table.tsx +++ b/ui/litellm-dashboard/src/components/view_key_table.tsx @@ -72,21 +72,6 @@ const ViewKeyTable: React.FC = ({ 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) { return; } @@ -147,7 +132,11 @@ const ViewKeyTable: React.FC = ({ {JSON.stringify(item.models)} - TPM Limit: {item.tpm_limit? item.tpm_limit : "Unlimited"}

RPM Limit: {item.rpm_limit? item.rpm_limit : "Unlimited"}
+ + TPM Limit: {item.tpm_limit ? item.tpm_limit : "Unlimited"}{" "} +

RPM Limit:{" "} + {item.rpm_limit ? item.rpm_limit : "Unlimited"} +
{item.expires != null ? ( @@ -180,12 +169,18 @@ const ViewKeyTable: React.FC = ({ {isDeleteModalOpen && (
-