diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index 7f135f8b79..dcabaac30c 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -16,5 +16,5 @@ model_list: prompt_id: "jokes" -litellm_settings: - callbacks: ["otel"] \ No newline at end of file +# litellm_settings: +# callbacks: ["otel"] \ No newline at end of file diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 62e4bed83c..b247c1cbe6 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -4,6 +4,7 @@ Endpoints for /organization operations /organization/new /organization/update /organization/delete +/organization/member_add /organization/info /organization/list """ @@ -230,7 +231,9 @@ async def list_organization( status_code=400, detail={"error": CommonProxyErrors.db_not_connected_error.value}, ) - response = await prisma_client.db.litellm_organizationtable.find_many() + response = await prisma_client.db.litellm_organizationtable.find_many( + include={"members": True} + ) return response diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 5b0340ef4b..69d70394f8 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -56,6 +56,7 @@ model LiteLLM_OrganizationTable { litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) teams LiteLLM_TeamTable[] users LiteLLM_UserTable[] + members LiteLLM_OrganizationMembership[] @relation("OrganizationToMembership") } // Model info for teams, just has model aliases for now. @@ -253,8 +254,11 @@ model LiteLLM_OrganizationMembership { // relations user LiteLLM_UserTable @relation(fields: [user_id], references: [user_id]) + organization LiteLLM_OrganizationTable @relation("OrganizationToMembership", fields: [organization_id], references: [organization_id]) litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) + + @@id([user_id, organization_id]) @@unique([user_id, organization_id]) } diff --git a/schema.prisma b/schema.prisma index 5b0340ef4b..69d70394f8 100644 --- a/schema.prisma +++ b/schema.prisma @@ -56,6 +56,7 @@ model LiteLLM_OrganizationTable { litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) teams LiteLLM_TeamTable[] users LiteLLM_UserTable[] + members LiteLLM_OrganizationMembership[] @relation("OrganizationToMembership") } // Model info for teams, just has model aliases for now. @@ -253,8 +254,11 @@ model LiteLLM_OrganizationMembership { // relations user LiteLLM_UserTable @relation(fields: [user_id], references: [user_id]) + organization LiteLLM_OrganizationTable @relation("OrganizationToMembership", fields: [organization_id], references: [organization_id]) litellm_budget_table LiteLLM_BudgetTable? @relation(fields: [budget_id], references: [budget_id]) + + @@id([user_id, organization_id]) @@unique([user_id, organization_id]) } diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 4f1c6411f2..fb28b6dfd2 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -6,6 +6,7 @@ import UserDashboard from "../components/user_dashboard"; import ModelDashboard from "@/components/model_dashboard"; import ViewUserDashboard from "@/components/view_users"; import Teams from "@/components/teams"; +import Organizations from "@/components/organizations"; import AdminPanel from "@/components/admins"; import Settings from "@/components/settings"; import GeneralSettings from "@/components/general_settings"; @@ -239,6 +240,15 @@ const CreateKeyPage = () => { userID={userID} userRole={userRole} /> + ) : page == "organizations" ? ( + ) : page == "admin-panel" ? ( React.ReactNode; + width?: string; + style?: React.CSSProperties; +} + +interface Action { + icon?: React.ComponentType; + onClick: (item: T) => void; + condition?: () => boolean; + tooltip?: string; +} + +interface DeleteModalProps { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; + title: string; + message: string; +} + +interface DataTableProps { + data: any[]; + columns: Column[]; + actions?: Action[]; + emptyMessage?: string; + deleteModal?: DeleteModalProps; +} + +const DataTable: React.FC = ({ + data, + columns, + actions, + emptyMessage = "No data available", + deleteModal +}) => { + const renderCell = (column: Column, row: any) => { + const value = row[column.accessor]; + + if (column.cellRenderer) { + return column.cellRenderer(value, row); + } + + // Default cell rendering based on value type + if (Array.isArray(value)) { + return ( +
+ {value.length === 0 ? ( + + None + + ) : ( + value.map((item: any, index: number) => ( + + + {String(item).length > 30 + ? `${String(item).slice(0, 30)}...` + : item} + + + )) + )} +
+ ); + } + + return value?.toString() || ''; + }; + + return ( + + + + + {columns.map((column, index) => ( + {column.header} + ))} + {actions && actions.length > 0 && ( + Actions + )} + + + + + {data && data.length > 0 ? ( + data.map((row, rowIndex) => ( + + {columns.map((column, colIndex) => ( + + {column.accessor === 'id' ? ( + + {renderCell(column, row)} + + ) : ( + renderCell(column, row) + )} + + ))} + {actions && actions.length > 0 && ( + + {actions.map((action, actionIndex) => ( + action.condition?.(row) !== false && ( + + action.onClick(row)} + className="cursor-pointer mx-1" + /> + + ) + ))} + + )} + + )) + ) : ( + + + {emptyMessage} + + + )} + +
+ +
+ ); +}; + +export default DataTable; +export type { Action, Column, DataTableProps, DeleteModalProps }; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index ba75193245..42ee4b9683 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -33,6 +33,7 @@ const menuItems: MenuItem[] = [ { key: "2", page: "models", label: "Models", roles: all_admin_roles }, { key: "4", page: "usage", label: "Usage"}, // all roles { key: "6", page: "teams", label: "Teams" }, + { key: "17", page: "organizations", label: "Organizations" }, { key: "5", page: "users", label: "Internal Users", roles: all_admin_roles }, { key: "8", page: "settings", label: "Logging & Alerts", roles: all_admin_roles }, { key: "9", page: "caching", label: "Caching", roles: all_admin_roles }, diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 3605d87f09..954f90c9b0 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -742,6 +742,70 @@ export const teamListCall = async ( } }; +export const organizationListCall = async (accessToken: String) => { + /** + * Get all organizations on proxy + */ + try { + let url = proxyBaseUrl ? `${proxyBaseUrl}/organization/list` : `/organization/list`; + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + return data; + } catch (error) { + console.error("Failed to create key:", error); + throw error; + } +}; + +export const organizationCreateCall = async ( + accessToken: string, + formValues: Record // Assuming formValues is an object +) => { + try { + console.log("Form Values in organizationCreateCall:", formValues); // Log the form values before making the API call + + const url = proxyBaseUrl ? `${proxyBaseUrl}/organization/new` : `/organization/new`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...formValues, // Include formValues in the request body + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + console.error("Error response from the server:", errorData); + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + console.log("API Response:", 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 create key:", error); + throw error; + } +}; + export const getTotalSpendCall = async (accessToken: String) => { /** * Get all models on proxy @@ -2203,6 +2267,46 @@ export const teamMemberAddCall = async ( } }; +export const organizationMemberAddCall = async ( + accessToken: string, + organizationId: string, + formValues: Member // Assuming formValues is an object +) => { + try { + console.log("Form Values in teamMemberAddCall:", formValues); // Log the form values before making the API call + + const url = proxyBaseUrl + ? `${proxyBaseUrl}/organization/member_add` + : `/organization/member_add`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + organization_id: organizationId, + member: formValues, // Include formValues in the request body + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + console.error("Error response from the server:", errorData); + throw new Error(errorData); + } + + const data = await response.json(); + console.log("API Response:", 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 create organization member:", error); + throw error; + } +}; + export const userUpdateUserCall = async ( accessToken: string, formValues: any, // Assuming formValues is an object diff --git a/ui/litellm-dashboard/src/components/organization/add_org.tsx b/ui/litellm-dashboard/src/components/organization/add_org.tsx new file mode 100644 index 0000000000..958f2205dc --- /dev/null +++ b/ui/litellm-dashboard/src/components/organization/add_org.tsx @@ -0,0 +1,184 @@ +import { + Button as Button2, + Modal, + Form, + Select as Select2, + InputNumber, + message, +} from "antd"; + +import { + TextInput, + Button, +} from "@tremor/react"; + +import { organizationCreateCall } from "../networking"; + +// types.ts +export interface FormData { + name: string; + models: string[]; + maxBudget: number | null; + budgetDuration: string | null; + tpmLimit: number | null; + rpmLimit: number | null; +} + +export interface OrganizationFormProps { + title?: string; + onCancel?: () => void; + accessToken: string | null; + availableModels?: string[]; + initialValues?: Partial; + submitButtonText?: string; + modelSelectionType?: 'single' | 'multiple'; +} + +// OrganizationForm.tsx +import React, { useState } from 'react'; + +const onSubmit = async (formValues: Record, accessToken: string | null, setIsModalVisible: any) => { + if (accessToken == null) { + return; + } + try { + message.info("Creating Organization"); + console.log("formValues: " + JSON.stringify(formValues)); + const response: any = await organizationCreateCall(accessToken, formValues); + console.log(`response for organization create call: ${response}`); + message.success("Organization created"); + sessionStorage.removeItem('organizations'); + setIsModalVisible(false); + } catch (error) { + console.error("Error creating the organization:", error); + message.error("Error creating the organization: " + error, 20); + } + +} + +const OrganizationForm: React.FC = ({ + title = "Create Organization", + onCancel, + accessToken, + availableModels = [], + initialValues = {}, + submitButtonText = "Create", + modelSelectionType = "multiple", +}) => { + const [form] = Form.useForm(); + const [isModalVisible, setIsModalVisible] = useState(false); + const [formData, setFormData] = useState({ + name: initialValues.name || '', + models: initialValues.models || [], + maxBudget: initialValues.maxBudget || null, + budgetDuration: initialValues.budgetDuration || null, + tpmLimit: initialValues.tpmLimit || null, + rpmLimit: initialValues.rpmLimit || null + }); + + console.log(`availableModels: ${availableModels}`) + + const handleSubmit = async (formValues: Record) => { + if (accessToken == null) { + return; + } + await onSubmit(formValues, accessToken, setIsModalVisible); + setIsModalVisible(false); + }; + + const handleCancel = (): void => { + setIsModalVisible(false); + if (onCancel) onCancel(); + }; + + return ( +
+ + + +
+ <> + + + + + + + All Proxy Models + + {availableModels.map((model) => ( + + {model} + + ))} + + + + + + + + + daily + weekly + monthly + + + + + + + + + +
+ {submitButtonText} +
+
+
+
+ ); +}; + +export default OrganizationForm; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/organization/add_org_admin.tsx b/ui/litellm-dashboard/src/components/organization/add_org_admin.tsx new file mode 100644 index 0000000000..5462f8ce2e --- /dev/null +++ b/ui/litellm-dashboard/src/components/organization/add_org_admin.tsx @@ -0,0 +1,149 @@ +/** + * This component is used to add an admin to an organization. + */ +import React, { FC } from 'react'; +import { + Button, + Select, + Col, + Text +} from "@tremor/react"; +import { + Button as Button2, + Select as Select2, + Modal, + Form, + Input, +} from "antd"; +import { Organization } from "@/components/organization/types"; +interface AddOrgAdminProps { + userRole: string; + userID: string; + selectedOrganization?: Organization; + onMemberAdd?: (formValues: Record) => void; + } + + +const is_org_admin = (organization: any, userID: string) => { + for (let i = 0; i < organization.members_with_roles.length; i++) { + let member = organization.members_with_roles[i]; + if (member.user_id == userID && member.role == "admin") { + return true; + } + } + return false; + } + +const AddOrgAdmin: FC = ({ + userRole, + userID, + selectedOrganization, + onMemberAdd +}) => { + const [isAddMemberModalVisible, setIsAddMemberModalVisible] = React.useState(false); + const [form] = Form.useForm(); + + const handleMemberCancel = () => { + form.resetFields(); + setIsAddMemberModalVisible(false); + }; + + const handleMemberOk = () => { + form.submit(); + }; + + return ( + + {userRole === "Admin" || (selectedOrganization && is_org_admin(selectedOrganization, userID)) ? ( + + ) : null} + + + + User must exist in proxy. Get User ID from 'Users' tab. + +
+ + + + +
OR
+ + + + + + + +
+ Org Admin{" "} +

+ Can add and remove members, and change their roles. +

+
+
+ +
+ Internal User{" "} +

+ Can view/create keys for themselves within organization. +

+
+
+ +
+ Internal User Viewer{" "} +

+ Can only view their keys within organization. +

+
+
+
+
+ +
+ Add member +
+
+
+ + ); +}; + +export default AddOrgAdmin; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/organization/types.tsx b/ui/litellm-dashboard/src/components/organization/types.tsx new file mode 100644 index 0000000000..13fa9e0829 --- /dev/null +++ b/ui/litellm-dashboard/src/components/organization/types.tsx @@ -0,0 +1,25 @@ +import { Member } from "../networking"; + + +export interface EditModalProps { + visible: boolean; + onCancel: () => void; + entity: Organization; + onSubmit: (entity: Organization) => void; +} + +interface OrganizationMember { + user_id: string; + user_role: string; +} + +export interface Organization { + organization_id: string; + organization_name: string; + spend: number; + max_budget: number | null; + models: string[]; + tpm_limit: number | null; + rpm_limit: number | null; + members: OrganizationMember[] | null; +} \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/organization/view_members_of_org.tsx b/ui/litellm-dashboard/src/components/organization/view_members_of_org.tsx new file mode 100644 index 0000000000..a5c11e5152 --- /dev/null +++ b/ui/litellm-dashboard/src/components/organization/view_members_of_org.tsx @@ -0,0 +1,88 @@ +import React, { FC } from 'react'; + +import { Organization, EditModalProps, OrganizationMember } from "./types"; +import { + TextInput, + Button, + Card, + Col, + Table, + TableHead, + TableHeaderCell, + TableBody, + TableRow, + TableCell, +} from "@tremor/react"; + +interface Member { + user_email?: string; + user_id?: string; + role: string; +} + + +interface MemberListTableProps { + selectedEntity?: Organization; + onEditSubmit: (entity: Organization) => void; + editModalComponent: React.ComponentType; + entityType: 'team' | 'organization'; +} + +const MemberListTable: FC = ({ + selectedEntity, + onEditSubmit, + editModalComponent: EditModal, + entityType +}) => { + const [editModalVisible, setEditModalVisible] = React.useState(false); + + const handleEditCancel = () => { + setEditModalVisible(false); + }; + + const handleEditSubmit = (entity: Organization) => { + onEditSubmit(entity); + setEditModalVisible(false); + }; + + const getMemberIdentifier = (member: Member) => { + return member.user_email || member.user_id || 'Unknown Member'; + }; + + return ( + + + + + + + {entityType === 'team' ? 'Team Member' : 'Organization Member'} + + Role + + + + + {(selectedEntity?.members ?? []).map((value: OrganizationMember, index: number) => ( + + {value.user_id} + {value.user_role} + + ))} + +
+
+ + {selectedEntity && ( + + )} + + ); +}; + +export default MemberListTable; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/organizations.tsx b/ui/litellm-dashboard/src/components/organizations.tsx new file mode 100644 index 0000000000..0dd6aeb107 --- /dev/null +++ b/ui/litellm-dashboard/src/components/organizations.tsx @@ -0,0 +1,273 @@ +import React, { useState, useEffect } from "react"; +import { organizationListCall, organizationMemberAddCall, Member, modelAvailableCall } from "./networking"; +import { + Col, + Grid, +} from "@tremor/react"; +import OrganizationForm from "@/components/organization/add_org"; +import AddOrgAdmin from "@/components/organization/add_org_admin"; +import MemberListTable from "@/components/organization/view_members_of_org"; +import { Select, SelectItem } from "@tremor/react"; +const isLocal = process.env.NODE_ENV === "development"; +const proxyBaseUrl = isLocal ? "http://localhost:4000" : null; +if (isLocal != true) { + console.log = function() {}; +} +interface TeamProps { + teams: any[] | null; + searchParams: any; + accessToken: string | null; + setTeams: React.Dispatch>; + userID: string | null; + userRole: string | null; +} + + +import DataTable from "@/components/common_components/all_view"; +import { Action } from "@/components/common_components/all_view"; +import { Typography, message } from "antd"; +import { Organization, EditModalProps } from "@/components/organization/types"; + +interface OrganizationsTableProps { + organizations: Organization[]; + userRole?: string; + onEdit?: (organization: Organization) => void; + onDelete?: (organization: Organization) => void; + isDeleteModalOpen: boolean; + setIsDeleteModalOpen: (value: boolean) => void; + selectedOrganization: Organization | null; + setSelectedOrganization: (value: Organization | null) => void; +} + + +const EditOrganizationModal: React.FC = ({ + visible, + onCancel, + entity, + onSubmit +}) => { + return
; +}; + + +// Inside your Teams component +const OrganizationsTable: React.FC = ({ + organizations, + userRole, + onEdit, + onDelete, + isDeleteModalOpen, + setIsDeleteModalOpen, + selectedOrganization, + setSelectedOrganization +}) => { + + const columns = [ + { + header: "Organization Name", + accessor: "organization_alias", + width: "4px", + style: { + whiteSpace: "pre-wrap", + overflow: "hidden" + } + }, + { + header: "Organization ID", + accessor: "organization_id", + width: "4px", + style: { + whiteSpace: "nowrap", + overflow: "hidden", + textOverflow: "ellipsis", + fontSize: "0.75em" + } + }, + { + header: "Spend (USD)", + accessor: "spend" + }, + { + header: "Budget (USD)", + accessor: "max_budget", + cellRenderer: (value: number | null) => + value !== null && value !== undefined ? value : "No limit" + }, + { + header: "Models", + accessor: "models" + }, + { + header: "TPM / RPM Limits", + accessor: "limits", + cellRenderer: (value: any, row: Organization) => ( +
+ TPM: {row.tpm_limit ? row.tpm_limit : "Unlimited"} +
+ RPM: {row.rpm_limit ? row.rpm_limit : "Unlimited"} +
+ ) + }, + { + header: "Info", + accessor: "info", + cellRenderer: (value: any, row: Organization) => ( +
+
+ {row.members?.length || 0} Members +
+
+ ) + } + ]; + + const actions: Action[] = [ + ...(onEdit && userRole === "Admin" ? [{ + icon: undefined, // Replace with your PencilAltIcon + onClick: (org: Organization) => onEdit(org), + condition: () => userRole === "Admin", + tooltip: "Edit organization" + }] : []), + ...(onDelete && userRole === "Admin" ? [{ + icon: undefined, // Replace with your TrashIcon + onClick: (org: Organization) => onDelete(org), + condition: () => userRole === "Admin", + tooltip: "Delete organization" + }] : []) + ]; + + return ( + { + if (selectedOrganization && onDelete) { + onDelete(selectedOrganization); + } + setIsDeleteModalOpen(false); + setSelectedOrganization(null); + }, + onCancel: () => { + setIsDeleteModalOpen(false); + setSelectedOrganization(null); + }, + title: "Delete Organization", + message: "Are you sure you want to delete this organization?" + }} + /> + ); +}; + +const Organizations: React.FC = ({ + accessToken, + userID, + userRole, +}) => { + const [organizations, setOrganizations] = useState([]); + const { Title, Paragraph } = Typography; + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedOrganization, setSelectedOrganization] = useState(null); + const [userModels, setUserModels] = useState([]); + + useEffect(() => { + if (!accessToken || !userID || !userRole) return; + + const fetchUserModels = async () => { + try { + const model_available = await modelAvailableCall( + accessToken, + userID, + userRole + ); + let available_model_names = model_available["data"].map( + (element: { id: string }) => element.id + ); + console.log("available_model_names:", available_model_names); + setUserModels(available_model_names); + } catch (error) { + console.error("Error fetching user models:", error); + } + }; + + const fetchData = async () => { + let givenOrganizations; + givenOrganizations = await organizationListCall(accessToken) + console.log(`givenOrganizations: ${givenOrganizations}`) + setOrganizations(givenOrganizations) + sessionStorage.setItem('organizations', JSON.stringify(givenOrganizations)); + } + fetchUserModels() + fetchData() + }, [accessToken]); + + const handleMemberCreate = async (formValues: Record) => { + if (!selectedOrganization || !accessToken) return; + try { + let member: Member = { + user_email: formValues.user_email, + user_id: formValues.user_id, + role: formValues.role + } + await organizationMemberAddCall( + accessToken, + selectedOrganization["organization_id"], + member + ); + message.success("Member added"); + } catch (error) { + console.error("Error creating the team:", error); + message.error("Error creating the organization: " + error); + } +}; + + return ( +
+ + + All Organizations + {userRole ? OrganizationsTable({organizations, userRole, isDeleteModalOpen, setIsDeleteModalOpen, selectedOrganization, setSelectedOrganization}) : null} + + {userRole == "Admin" && accessToken ? : null} + + Organization Members + + If you belong to multiple organizations, this setting controls which organizations' + members you see. + + {organizations && organizations.length > 0 ? ( + + ) : ( + + No team created. Defaulting to personal account. + + )} + + {userRole == "Admin" && userID && selectedOrganization ? : null} + {userRole == "Admin" && userID && selectedOrganization ? {}} editModalComponent={EditOrganizationModal} entityType="organization" /> : null} + +
+ ); +}; + +export default Organizations; diff --git a/ui/litellm-dashboard/src/components/teams.tsx b/ui/litellm-dashboard/src/components/teams.tsx index db83fd5321..3a883d4cee 100644 --- a/ui/litellm-dashboard/src/components/teams.tsx +++ b/ui/litellm-dashboard/src/components/teams.tsx @@ -655,9 +655,9 @@ const Team: React.FC = ({ {userRole == "Admin"? (