diff --git a/litellm/proxy/_experimental/out/404.html b/litellm/proxy/_experimental/out/404.html deleted file mode 100644 index 41cc292f2..000000000 --- a/litellm/proxy/_experimental/out/404.html +++ /dev/null @@ -1 +0,0 @@ -404: This page could not be found.LiteLLM Dashboard

404

This page could not be found.

\ No newline at end of file diff --git a/litellm/proxy/_experimental/out/model_hub.html b/litellm/proxy/_experimental/out/model_hub.html deleted file mode 100644 index 80a9a9ee0..000000000 --- a/litellm/proxy/_experimental/out/model_hub.html +++ /dev/null @@ -1 +0,0 @@ -LiteLLM Dashboard \ No newline at end of file diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding.html deleted file mode 100644 index 50b15455f..000000000 --- a/litellm/proxy/_experimental/out/onboarding.html +++ /dev/null @@ -1 +0,0 @@ -LiteLLM Dashboard \ No newline at end of file diff --git a/litellm/proxy/_super_secret_config.yaml b/litellm/proxy/_super_secret_config.yaml index 3bc516604..c6b0bb011 100644 --- a/litellm/proxy/_super_secret_config.yaml +++ b/litellm/proxy/_super_secret_config.yaml @@ -15,9 +15,9 @@ model_list: # rpm: 10 # model_name: gpt-3.5-turbo-fake-model - litellm_params: - api_base: https://openai-gpt-4-test-v-1.openai.azure.com/ + api_base: https://openai-gpt-4-test-v-1.openai.azure.com api_key: os.environ/AZURE_API_KEY - api_version: '2023-05-15' + api_version: 2024-02-15-preview model: azure/chatgpt-v-2 model_name: gpt-3.5-turbo - litellm_params: @@ -26,10 +26,10 @@ model_list: - litellm_params: api_base: https://openai-gpt-4-test-v-1.openai.azure.com/ api_key: os.environ/AZURE_API_KEY - api_version: '2023-05-15' + api_version: 2024-02-15-preview model: azure/chatgpt-v-2 drop_params: True - model_name: gpt-3.5-turbo-drop-params + model_name: gpt-3.5-turbo - model_name: tts litellm_params: model: openai/tts-1 @@ -37,6 +37,7 @@ model_list: litellm_params: api_base: https://openai-france-1234.openai.azure.com api_key: os.environ/AZURE_FRANCE_API_KEY + api_version: 2024-02-15-preview model: azure/gpt-turbo - model_name: text-embedding litellm_params: diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index e5ea35a39..8a0369892 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -190,6 +190,7 @@ class LiteLLMRoutes(enum.Enum): "/model/info", "/v2/model/info", "/v2/key/info", + "/model_group/info", ] # NOTE: ROUTES ONLY FOR MASTER KEY - only the Master Key should be able to Reset Spend @@ -1343,6 +1344,12 @@ class InvitationModel(LiteLLMBase): updated_by: str +class InvitationClaim(LiteLLMBase): + invitation_link: str + user_id: str + password: str + + class ConfigFieldInfo(LiteLLMBase): field_name: str field_value: Any diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 4c6e933f4..5cf6d12ed 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1311,7 +1311,9 @@ async def user_api_key_auth( if user_id != valid_token.user_id: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="key not allowed to access this user's info", + detail="key not allowed to access this user's info. user_id={}, key's user_id={}".format( + user_id, valid_token.user_id + ), ) elif route == "/model/info": # /model/info just shows models user has access to @@ -1406,7 +1408,7 @@ async def user_api_key_auth( "/global/predict/spend/logs", "/global/activity", "/health/services", - ] + ] + LiteLLMRoutes.info_routes.value # check if the current route startswith any of the allowed routes if ( route is not None @@ -9164,6 +9166,7 @@ async def new_user(data: NewUserRequest): data_json["table_name"] = ( "user" # only create a user, don't create key if 'auto_create_key' set to False ) + response = await generate_key_helper_fn(request_type="user", **data_json) # Admin UI Logic @@ -12765,7 +12768,10 @@ async def login(request: Request): _password = getattr(_user_row, "password", "unknown") # check if password == _user_row.password - if secrets.compare_digest(password, _password): + hash_password = hash_token(token=password) + if secrets.compare_digest(password, _password) or secrets.compare_digest( + hash_password, _password + ): if os.getenv("DATABASE_URL") is not None: response = await generate_key_helper_fn( request_type="key", @@ -12928,6 +12934,92 @@ async def onboarding(invite_link: str): } +@app.post("/onboarding/claim_token", include_in_schema=False) +async def claim_onboarding_link(data: InvitationClaim): + """ + Special route. Allows UI link share user to update their password. + + - Get the invite link + - Validate it's still 'valid' + - Check if user within initial session (prevents abuse) + - Get user from db + - Update user password + + This route can only update user password. + """ + global prisma_client + ### VALIDATE INVITE LINK ### + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + + invite_obj = await prisma_client.db.litellm_invitationlink.find_unique( + where={"id": data.invitation_link} + ) + if invite_obj is None: + raise HTTPException( + status_code=401, detail={"error": "Invitation link does not exist in db."} + ) + #### CHECK IF EXPIRED + # Extract the date part from both datetime objects + utc_now_date = litellm.utils.get_utc_datetime().date() + expires_at_date = invite_obj.expires_at.date() + if expires_at_date < utc_now_date: + raise HTTPException( + status_code=401, detail={"error": "Invitation link has expired."} + ) + + #### CHECK IF CLAIMED + ##### if claimed - check if within valid session (within 10 minutes of being claimed) + ##### if unclaimed - reject + + current_time = litellm.utils.get_utc_datetime() + + if invite_obj.is_accepted == True: + time_difference = current_time - invite_obj.updated_at + + # Check if the difference is within 10 minutes + if time_difference > timedelta(minutes=10): + raise HTTPException( + status_code=401, + detail={ + "error": "The invitation link has already been claimed. Please ask your admin for a new invite link." + }, + ) + else: + raise HTTPException( + status_code=401, + detail={ + "error": "The invitation link was never validated. Please file an issue, if this is not intended - https://github.com/BerriAI/litellm/issues." + }, + ) + + #### CHECK IF VALID USER ID + if invite_obj.user_id != data.user_id: + raise HTTPException( + status_code=401, + detail={ + "error": "Invalid invitation link. The user id submitted does not match the user id this link is attached to. Got={}, Expected={}".format( + data.user_id, invite_obj.user_id + ) + }, + ) + ### UPDATE USER OBJECT ### + hash_password = hash_token(token=data.password) + user_obj = await prisma_client.db.litellm_usertable.update( + where={"user_id": invite_obj.user_id}, data={"password": hash_password} + ) + + if user_obj is None: + raise HTTPException( + status_code=401, detail={"error": "User does not exist in db."} + ) + + return user_obj + + @app.get("/get_image", include_in_schema=False) def get_image(): """Get logo to show on admin UI""" diff --git a/ui/litellm-dashboard/src/app/onboarding/page.tsx b/ui/litellm-dashboard/src/app/onboarding/page.tsx index 9803cf40a..44c37610c 100644 --- a/ui/litellm-dashboard/src/app/onboarding/page.tsx +++ b/ui/litellm-dashboard/src/app/onboarding/page.tsx @@ -1,12 +1,22 @@ "use client"; import React, { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; -import { Card, Title, Text, TextInput, Callout, Button, Grid, Col } from "@tremor/react"; -import { RiAlarmWarningLine, RiCheckboxCircleLine } from '@remixicon/react'; +import { + Card, + Title, + Text, + TextInput, + Callout, + Button, + Grid, + Col, +} from "@tremor/react"; +import { RiAlarmWarningLine, RiCheckboxCircleLine } from "@remixicon/react"; import { invitationClaimCall, userUpdateUserCall, getOnboardingCredentials, + claimOnboardingToken, } from "@/components/networking"; import { jwtDecode } from "jwt-decode"; import { Form, Button as Button2, message } from "antd"; @@ -18,6 +28,7 @@ export default function Onboarding() { const [accessToken, setAccessToken] = useState(null); const [defaultUserEmail, setDefaultUserEmail] = useState(""); const [userEmail, setUserEmail] = useState(""); + const [userID, setUserID] = useState(null); const [loginUrl, setLoginUrl] = useState(""); const [jwtToken, setJwtToken] = useState(""); @@ -26,11 +37,10 @@ export default function Onboarding() { return; } getOnboardingCredentials(inviteID).then((data) => { - const login_url = data.login_url; + const login_url = data.login_url; console.log("login_url:", login_url); setLoginUrl(login_url); - const token = data.token; const decoded = jwtDecode(token) as { [key: string]: any }; setJwtToken(token); @@ -42,31 +52,44 @@ export default function Onboarding() { const user_email = decoded.user_email; setUserEmail(user_email); + const user_id = decoded.user_id; + setUserID(user_id); }); - }, [inviteID]); - const handleSubmit = (formValues: Record) => { - console.log("in handle submit. accessToken:", accessToken, "token:", jwtToken, "formValues:", formValues); + console.log( + "in handle submit. accessToken:", + accessToken, + "token:", + jwtToken, + "formValues:", + formValues + ); if (!accessToken || !jwtToken) { return; } formValues.user_email = userEmail; - userUpdateUserCall(accessToken, formValues, null).then((data) => { + if (!userID || !inviteID) { + return; + } + claimOnboardingToken( + accessToken, + inviteID, + userID, + formValues.password + ).then((data) => { let litellm_dashboard_ui = "/ui/"; const user_id = data.data?.user_id || data.user_id; litellm_dashboard_ui += "?userID=" + user_id + "&token=" + jwtToken; console.log("redirecting to:", litellm_dashboard_ui); window.location.href = litellm_dashboard_ui; - }); // redirect to login page - }; return (
@@ -81,33 +104,26 @@ export default function Onboarding() { icon={RiCheckboxCircleLine} color="sky" > - - - SSO is under the Enterprise Tirer. - + + SSO is under the Enterprise Tirer. - - - - - + + + + - +
<> - + diff --git a/ui/litellm-dashboard/src/components/admins.tsx b/ui/litellm-dashboard/src/components/admins.tsx index fbcb20e5c..3b012d5b4 100644 --- a/ui/litellm-dashboard/src/components/admins.tsx +++ b/ui/litellm-dashboard/src/components/admins.tsx @@ -33,6 +33,8 @@ import { Divider, } from "@tremor/react"; import { PencilAltIcon } from "@heroicons/react/outline"; +import OnboardingModal from "./onboarding_link"; +import { InvitationLink } from "./onboarding_link"; interface AdminPanelProps { searchParams: any; accessToken: string | null; @@ -40,17 +42,6 @@ interface AdminPanelProps { showSSOBanner: boolean; } -interface InvitationLink { - id: string; - user_id: string; - is_accepted: boolean; - accepted_at: Date | null; - expires_at: Date; - created_at: Date; - created_by: string; - updated_at: Date; - updated_by: string; -} import { userUpdateUserCall, Member, @@ -84,11 +75,15 @@ const AdminPanel: React.FC = ({ useState(false); const router = useRouter(); - const [possibleUIRoles, setPossibleUIRoles] = useState>>(null); + const [possibleUIRoles, setPossibleUIRoles] = useState + >>(null); const isLocal = process.env.NODE_ENV === "development"; - const [baseUrl, setBaseUrl] = useState(isLocal ? "http://localhost:4000" : ""); - + const [baseUrl, setBaseUrl] = useState( + isLocal ? "http://localhost:4000" : "" + ); let nonSssoUrl; try { @@ -346,7 +341,6 @@ const AdminPanel: React.FC = ({ setIsInvitationLinkModalVisible(true); }); - const foundIndex = admins.findIndex((user) => { console.log( `user.user_id=${user.user_id}; response.user_id=${response.user_id}` @@ -462,7 +456,11 @@ const AdminPanel: React.FC = ({ ? member["user_id"] : null} - {possibleUIRoles?.[member?.user_role]?.ui_label || "-"} + + {" "} + {possibleUIRoles?.[member?.user_role]?.ui_label || + "-"} + = ({ > {addMemberForm(handleAdminCreate)} - - {/* {JSON.stringify(invitationLinkData)} */} - - Copy and send the generated link to onboard this user to the - proxy. - -
- User ID - {invitationLinkData?.user_id} -
-
- Invitation Link - - {baseUrl}/ui/onboarding?id={invitationLinkData?.id} - -
-
-
- message.success("Copied!")} - > - - -
-
+
); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 2b89c5ef1..e436c7808 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -522,6 +522,12 @@ export const userInfoCall = async ( if (userRole == "App User" && userID) { url = `${url}?user_id=${userID}`; } + if ( + (userRole == "Internal User" || userRole == "Internal Viewer") && + userID + ) { + url = `${url}?user_id=${userID}`; + } console.log("in userInfoCall viewAll=", viewAll); if (viewAll && page_size && page != null && page != undefined) { url = `${url}?view_all=true&page=${page}&page_size=${page_size}`; @@ -617,14 +623,15 @@ export const getTotalSpendCall = async (accessToken: String) => { } }; - export const getOnboardingCredentials = async (inviteUUID: String) => { /** * Get all models on proxy */ try { - let url = proxyBaseUrl ? `${proxyBaseUrl}/onboarding/get_token` : `/onboarding/get_token`; - url += `?invite_link=${inviteUUID}` + let url = proxyBaseUrl + ? `${proxyBaseUrl}/onboarding/get_token` + : `/onboarding/get_token`; + url += `?invite_link=${inviteUUID}`; const response = await fetch(url, { method: "GET", @@ -648,6 +655,43 @@ export const getOnboardingCredentials = async (inviteUUID: String) => { } }; +export const claimOnboardingToken = async ( + accessToken: string, + inviteUUID: string, + userID: string, + password: String +) => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/onboarding/claim_token` + : `/onboarding/claim_token`; + try { + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + invitation_link: inviteUUID, + user_id: userID, + password: password, + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + message.error("Failed to delete team: " + errorData, 10); + throw new Error("Network response was not ok"); + } + const data = await response.json(); + console.log(data); + return data; + // Handle success - you might want to update some state or UI based on the created key + } catch (error) { + console.error("Failed to delete key:", error); + throw error; + } +}; export const modelInfoCall = async ( accessToken: String, userID: String, @@ -1024,15 +1068,12 @@ export const tagsSpendLogsCall = async ( } }; -export const allTagNamesCall = async ( - accessToken: String, -) => { +export const allTagNamesCall = async (accessToken: String) => { try { let url = proxyBaseUrl ? `${proxyBaseUrl}/global/spend/all_tag_names` : `/global/spend/all_tag_names`; - console.log("in global/spend/all_tag_names call", url); const response = await fetch(`${url}`, { method: "GET", @@ -1055,16 +1096,12 @@ export const allTagNamesCall = async ( } }; - -export const allEndUsersCall = async ( - accessToken: String, -) => { +export const allEndUsersCall = async (accessToken: String) => { try { let url = proxyBaseUrl ? `${proxyBaseUrl}/global/all_end_users` : `/global/all_end_users`; - console.log("in global/all_end_users call", url); const response = await fetch(`${url}`, { method: "GET", @@ -1087,7 +1124,6 @@ export const allEndUsersCall = async ( } }; - export const userSpendLogsCall = async ( accessToken: String, token: String, @@ -1377,13 +1413,11 @@ export const adminGlobalActivityPerModel = async ( } }; - - export const adminGlobalActivityExceptions = async ( accessToken: String, startTime: String | undefined, endTime: String | undefined, - modelGroup: String, + modelGroup: String ) => { try { let url = proxyBaseUrl @@ -1429,7 +1463,7 @@ export const adminGlobalActivityExceptionsPerDeployment = async ( accessToken: String, startTime: String | undefined, endTime: String | undefined, - modelGroup: String, + modelGroup: String ) => { try { let url = proxyBaseUrl @@ -1666,9 +1700,7 @@ export const userGetAllUsersCall = async ( } }; -export const getPossibleUserRoles = async ( - accessToken: String, -) => { +export const getPossibleUserRoles = async (accessToken: String) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/user/available_roles` diff --git a/ui/litellm-dashboard/src/components/onboarding_link.tsx b/ui/litellm-dashboard/src/components/onboarding_link.tsx new file mode 100644 index 000000000..09a635a14 --- /dev/null +++ b/ui/litellm-dashboard/src/components/onboarding_link.tsx @@ -0,0 +1,86 @@ +import React, { useState, useEffect } from "react"; +import { + Button as Button2, + Modal, + Form, + Input, + Select as Select2, + InputNumber, + message, + Typography, +} from "antd"; +import { CopyToClipboard } from "react-copy-to-clipboard"; +import { Text, Button } from "@tremor/react"; +export interface InvitationLink { + id: string; + user_id: string; + is_accepted: boolean; + accepted_at: Date | null; + expires_at: Date; + created_at: Date; + created_by: string; + updated_at: Date; + updated_by: string; +} + +interface OnboardingProps { + isInvitationLinkModalVisible: boolean; + setIsInvitationLinkModalVisible: React.Dispatch< + React.SetStateAction + >; + baseUrl: string; + invitationLinkData: InvitationLink | null; +} + +const OnboardingModal: React.FC = ({ + isInvitationLinkModalVisible, + setIsInvitationLinkModalVisible, + baseUrl, + invitationLinkData, +}) => { + const { Title, Paragraph } = Typography; + const handleInvitationOk = () => { + setIsInvitationLinkModalVisible(false); + }; + + const handleInvitationCancel = () => { + setIsInvitationLinkModalVisible(false); + }; + + return ( + + {/* {JSON.stringify(invitationLinkData)} */} + + Copy and send the generated link to onboard this user to the proxy. + +
+ User ID + {invitationLinkData?.user_id} +
+
+ Invitation Link + + {baseUrl}/ui/onboarding?id={invitationLinkData?.id} + +
+
+
+ message.success("Copied!")} + > + + +
+
+ ); +}; + +export default OnboardingModal; diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index 1a2439055..d214e13ee 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -101,7 +101,7 @@ const UserDashboard: React.FC = ({ return "App User"; case "internal_user": return "Internal User"; - case "internal_viewer": + case "internal_user_viewer": return "Internal Viewer"; default: return "Unknown Role"; diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index 1b7d2fac6..6b1deee6e 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -25,15 +25,17 @@ import { TextInput, } from "@tremor/react"; -import { - message, -} from "antd"; +import { message } from "antd"; -import { userInfoCall, userUpdateUserCall, getPossibleUserRoles } from "./networking"; +import { + userInfoCall, + userUpdateUserCall, + getPossibleUserRoles, +} from "./networking"; import { Badge, BadgeDelta, Button } from "@tremor/react"; import RequestAccess from "./request_model_access"; import CreateUser from "./create_user_button"; -import EditUserModal from "./edit_user"; +import EditUserModal from "./edit_user"; import Paragraph from "antd/es/skeleton/Paragraph"; import { PencilAltIcon, @@ -67,14 +69,16 @@ const ViewUserDashboard: React.FC = ({ const [selectedItem, setSelectedItem] = useState(null); const [editModalVisible, setEditModalVisible] = useState(false); const [selectedUser, setSelectedUser] = useState(null); - const [possibleUIRoles, setPossibleUIRoles] = useState>>({}); + const [possibleUIRoles, setPossibleUIRoles] = useState< + Record> + >({}); const defaultPageSize = 25; const handleEditCancel = async () => { setSelectedUser(null); setEditModalVisible(false); }; - + const handleEditSubmit = async (editedUser: any) => { console.log("inside handleEditSubmit:", editedUser); @@ -87,7 +91,7 @@ const ViewUserDashboard: React.FC = ({ message.success(`User ${editedUser.user_id} updated successfully`); } catch (error) { console.error("There was an error updating the user", error); - } + } if (userData) { const updatedUserData = userData.map((user) => user.user_id === editedUser.user_id ? editedUser : user @@ -119,13 +123,11 @@ const ViewUserDashboard: React.FC = ({ const availableUserRoles = await getPossibleUserRoles(accessToken); setPossibleUIRoles(availableUserRoles); - } catch (error) { console.error("There was an error fetching the model data", error); } }; - if (accessToken && token && userRole && userID) { fetchData(); } @@ -174,10 +176,14 @@ const ViewUserDashboard: React.FC = ({ return (
- + -
-
+
@@ -218,9 +224,8 @@ const ViewUserDashboard: React.FC = ({ user.key_aliases.filter( (key: any) => key !== null ).length - } -  Keys +  Keys ) : ( @@ -233,23 +238,24 @@ const ViewUserDashboard: React.FC = ({ )} {/* {user.key_aliases.filter(key => key !== null).length} Keys */} -
- + + - - { - setSelectedUser(user) - setEditModalVisible(true) - }}>View Keys -{/* + { + setSelectedUser(user); + setEditModalVisible(true); + }} + > + View Keys + + {/* { setOpenDialogId(user.user_id) setSelectedItem(user) }}>View Keys */} - - - ))} @@ -283,12 +289,12 @@ const ViewUserDashboard: React.FC = ({ + visible={editModalVisible} + possibleUIRoles={possibleUIRoles} + onCancel={handleEditCancel} + user={selectedUser} + onSubmit={handleEditSubmit} + /> {renderPagination()}