mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-25 18:54:30 +00:00
Create and view organizations + assign org admins on the Proxy UI (#7557)
* feat: initial commit for new 'organizations' tab on ui * build(ui/): create generic card for rendering complete org data table can be reused in teams as well simplifies things * build(ui/): display created orgs on ui * build(ui/): support adding orgs via UI * build(ui/): add org in selection dropdown * build(organizations.tsx): allow assigning org admins * build(ui/): show org members on ui * build(ui/): cleanup + show actual models on org dropdown * build(ui/): explain user roles within organization
This commit is contained in:
parent
46d9d29bff
commit
f1540ceeab
14 changed files with 1014 additions and 6 deletions
|
@ -16,5 +16,5 @@ model_list:
|
|||
prompt_id: "jokes"
|
||||
|
||||
|
||||
litellm_settings:
|
||||
callbacks: ["otel"]
|
||||
# litellm_settings:
|
||||
# callbacks: ["otel"]
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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" ? (
|
||||
<Organizations
|
||||
teams={teams}
|
||||
setTeams={setTeams}
|
||||
searchParams={searchParams}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
/>
|
||||
) : page == "admin-panel" ? (
|
||||
<AdminPanel
|
||||
setTeams={setTeams}
|
||||
|
|
|
@ -0,0 +1,163 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
Text,
|
||||
Badge,
|
||||
Icon,
|
||||
Button,
|
||||
Card,
|
||||
} from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
|
||||
interface Column {
|
||||
header: string;
|
||||
accessor: string;
|
||||
cellRenderer?: (value: any, row: any) => React.ReactNode;
|
||||
width?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
interface Action<T = any> {
|
||||
icon?: React.ComponentType<any>;
|
||||
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<DataTableProps> = ({
|
||||
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 (
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
{value.length === 0 ? (
|
||||
<Badge size="xs" className="mb-1" color="red">
|
||||
<Text>None</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
value.map((item: any, index: number) => (
|
||||
<Badge
|
||||
key={index}
|
||||
size="xs"
|
||||
className="mb-1"
|
||||
color="blue"
|
||||
>
|
||||
<Text>
|
||||
{String(item).length > 30
|
||||
? `${String(item).slice(0, 30)}...`
|
||||
: item}
|
||||
</Text>
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return value?.toString() || '';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[40vh]">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((column, index) => (
|
||||
<TableHeaderCell key={index}>{column.header}</TableHeaderCell>
|
||||
))}
|
||||
{actions && actions.length > 0 && (
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{data && data.length > 0 ? (
|
||||
data.map((row, rowIndex) => (
|
||||
<TableRow key={rowIndex}>
|
||||
{columns.map((column, colIndex) => (
|
||||
<TableCell
|
||||
key={colIndex}
|
||||
style={{
|
||||
maxWidth: column.width || "4px",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
...column.style
|
||||
}}
|
||||
>
|
||||
{column.accessor === 'id' ? (
|
||||
<Tooltip title={row[column.accessor]}>
|
||||
{renderCell(column, row)}
|
||||
</Tooltip>
|
||||
) : (
|
||||
renderCell(column, row)
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
{actions && actions.length > 0 && (
|
||||
<TableCell>
|
||||
{actions.map((action, actionIndex) => (
|
||||
action.condition?.(row) !== false && (
|
||||
<Tooltip key={actionIndex} title={action.tooltip}>
|
||||
<Icon
|
||||
icon={action.icon}
|
||||
size="sm"
|
||||
onClick={() => action.onClick(row)}
|
||||
className="cursor-pointer mx-1"
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
))}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length + (actions ? 1 : 0)}>
|
||||
<Text className="text-center">{emptyMessage}</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataTable;
|
||||
export type { Action, Column, DataTableProps, DeleteModalProps };
|
|
@ -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 },
|
||||
|
|
|
@ -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<string, any> // 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
|
||||
|
|
184
ui/litellm-dashboard/src/components/organization/add_org.tsx
Normal file
184
ui/litellm-dashboard/src/components/organization/add_org.tsx
Normal file
|
@ -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<FormData>;
|
||||
submitButtonText?: string;
|
||||
modelSelectionType?: 'single' | 'multiple';
|
||||
}
|
||||
|
||||
// OrganizationForm.tsx
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const onSubmit = async (formValues: Record<string, any>, 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<OrganizationFormProps> = ({
|
||||
title = "Create Organization",
|
||||
onCancel,
|
||||
accessToken,
|
||||
availableModels = [],
|
||||
initialValues = {},
|
||||
submitButtonText = "Create",
|
||||
modelSelectionType = "multiple",
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [isModalVisible, setIsModalVisible] = useState<boolean>(false);
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
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<string, any>) => {
|
||||
if (accessToken == null) {
|
||||
return;
|
||||
}
|
||||
await onSubmit(formValues, accessToken, setIsModalVisible);
|
||||
setIsModalVisible(false);
|
||||
};
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setIsModalVisible(false);
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Button
|
||||
onClick={() => setIsModalVisible(true)}
|
||||
className="mx-auto"
|
||||
type="button"
|
||||
>
|
||||
+ Create New {title}
|
||||
</Button>
|
||||
|
||||
<Modal
|
||||
title={`Create ${title}`}
|
||||
visible={isModalVisible}
|
||||
width={800}
|
||||
footer={null}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleSubmit}
|
||||
labelCol={{ span: 8 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<>
|
||||
<Form.Item
|
||||
label={`${title} Name`}
|
||||
name="organization_alias"
|
||||
rules={[
|
||||
{ required: true, message: `Please input a ${title} name` },
|
||||
]}
|
||||
>
|
||||
<TextInput placeholder="" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Models" name="models">
|
||||
<Select2
|
||||
mode="multiple"
|
||||
placeholder="Select models"
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Select2.Option
|
||||
key="all-proxy-models"
|
||||
value="all-proxy-models"
|
||||
>
|
||||
All Proxy Models
|
||||
</Select2.Option>
|
||||
{availableModels.map((model) => (
|
||||
<Select2.Option key={model} value={model}>
|
||||
{model}
|
||||
</Select2.Option>
|
||||
))}
|
||||
</Select2>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Max Budget (USD)" name="max_budget">
|
||||
<InputNumber step={0.01} precision={2} width={200} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="mt-8"
|
||||
label="Reset Budget"
|
||||
name="budget_duration"
|
||||
>
|
||||
<Select2 defaultValue={null} placeholder="n/a">
|
||||
<Select2.Option value="24h">daily</Select2.Option>
|
||||
<Select2.Option value="7d">weekly</Select2.Option>
|
||||
<Select2.Option value="30d">monthly</Select2.Option>
|
||||
</Select2>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Tokens per minute Limit (TPM)"
|
||||
name="tpm_limit"
|
||||
>
|
||||
<InputNumber step={1} width={400} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Requests per minute Limit (RPM)"
|
||||
name="rpm_limit"
|
||||
>
|
||||
<InputNumber step={1} width={400} />
|
||||
</Form.Item>
|
||||
</>
|
||||
<div style={{ textAlign: "right", marginTop: "10px" }}>
|
||||
<Button2 htmlType="submit">{submitButtonText}</Button2>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationForm;
|
|
@ -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<string, any>) => 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<AddOrgAdminProps> = ({
|
||||
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 (
|
||||
<Col numColSpan={1}>
|
||||
{userRole === "Admin" || (selectedOrganization && is_org_admin(selectedOrganization, userID)) ? (
|
||||
<Button
|
||||
className="mx-auto mb-5"
|
||||
onClick={() => setIsAddMemberModalVisible(true)}
|
||||
>
|
||||
+ Add member
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<Modal
|
||||
title="Add member"
|
||||
visible={isAddMemberModalVisible}
|
||||
width={800}
|
||||
footer={null}
|
||||
onOk={handleMemberOk}
|
||||
onCancel={handleMemberCancel}
|
||||
>
|
||||
<Text className='mb-2'>
|
||||
User must exist in proxy. Get User ID from 'Users' tab.
|
||||
</Text>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={onMemberAdd}
|
||||
labelCol={{ span: 8 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
labelAlign="left"
|
||||
initialValues={{
|
||||
role: "internal_user",
|
||||
}}
|
||||
>
|
||||
<Form.Item label="Email" name="user_email" className="mb-4">
|
||||
<Input
|
||||
name="user_email"
|
||||
className="px-3 py-2 border rounded-md w-full"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="text-center mb-4">OR</div>
|
||||
|
||||
<Form.Item label="User ID" name="user_id" className="mb-4">
|
||||
<Input
|
||||
name="user_id"
|
||||
className="px-3 py-2 border rounded-md w-full"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Member Role" name="role" className="mb-4">
|
||||
<Select2 defaultValue="user">
|
||||
<Select2.Option value="org_admin"><div className="flex">
|
||||
Org Admin{" "}
|
||||
<p
|
||||
className="ml-2"
|
||||
style={{ color: "gray", fontSize: "12px" }}
|
||||
>
|
||||
Can add and remove members, and change their roles.
|
||||
</p>
|
||||
</div>
|
||||
</Select2.Option>
|
||||
<Select2.Option value="internal_user">
|
||||
<div className="flex">
|
||||
Internal User{" "}
|
||||
<p
|
||||
className="ml-2"
|
||||
style={{ color: "gray", fontSize: "12px" }}
|
||||
>
|
||||
Can view/create keys for themselves within organization.
|
||||
</p>
|
||||
</div>
|
||||
</Select2.Option>
|
||||
<Select2.Option value="internal_user_viewer">
|
||||
<div className="flex">
|
||||
Internal User Viewer{" "}
|
||||
<p
|
||||
className="ml-2"
|
||||
style={{ color: "gray", fontSize: "12px" }}
|
||||
>
|
||||
Can only view their keys within organization.
|
||||
</p>
|
||||
</div>
|
||||
</Select2.Option>
|
||||
</Select2>
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: "right", marginTop: "10px" }}>
|
||||
<Button2 htmlType="submit">Add member</Button2>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddOrgAdmin;
|
25
ui/litellm-dashboard/src/components/organization/types.tsx
Normal file
25
ui/litellm-dashboard/src/components/organization/types.tsx
Normal file
|
@ -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;
|
||||
}
|
|
@ -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<EditModalProps>;
|
||||
entityType: 'team' | 'organization';
|
||||
}
|
||||
|
||||
const MemberListTable: FC<MemberListTableProps> = ({
|
||||
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 (
|
||||
<Col numColSpan={1}>
|
||||
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>
|
||||
{entityType === 'team' ? 'Team Member' : 'Organization Member'}
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
|
||||
<TableBody>
|
||||
{(selectedEntity?.members ?? []).map((value: OrganizationMember, index: number) => (
|
||||
<TableRow key={`${value.user_id}-${index}`}>
|
||||
<TableCell>{value.user_id}</TableCell>
|
||||
<TableCell>{value.user_role}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
{selectedEntity && (
|
||||
<EditModal
|
||||
visible={editModalVisible}
|
||||
onCancel={handleEditCancel}
|
||||
entity={selectedEntity}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberListTable;
|
273
ui/litellm-dashboard/src/components/organizations.tsx
Normal file
273
ui/litellm-dashboard/src/components/organizations.tsx
Normal file
|
@ -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<React.SetStateAction<Object[] | null>>;
|
||||
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<EditModalProps> = ({
|
||||
visible,
|
||||
onCancel,
|
||||
entity,
|
||||
onSubmit
|
||||
}) => {
|
||||
return <div/>;
|
||||
};
|
||||
|
||||
|
||||
// Inside your Teams component
|
||||
const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
|
||||
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) => (
|
||||
<div className="text-sm">
|
||||
<span>TPM: {row.tpm_limit ? row.tpm_limit : "Unlimited"}</span>
|
||||
<br />
|
||||
<span>RPM: {row.rpm_limit ? row.rpm_limit : "Unlimited"}</span>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
header: "Info",
|
||||
accessor: "info",
|
||||
cellRenderer: (value: any, row: Organization) => (
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm">
|
||||
{row.members?.length || 0} Members
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
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 (
|
||||
<DataTable
|
||||
data={organizations}
|
||||
columns={columns}
|
||||
actions={actions}
|
||||
emptyMessage="No organizations available"
|
||||
deleteModal={{
|
||||
isOpen: isDeleteModalOpen,
|
||||
onConfirm: () => {
|
||||
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<TeamProps> = ({
|
||||
accessToken,
|
||||
userID,
|
||||
userRole,
|
||||
}) => {
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const { Title, Paragraph } = Typography;
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedOrganization, setSelectedOrganization] = useState<Organization | null>(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<string, any>) => {
|
||||
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 (
|
||||
<div className="w-full mx-4">
|
||||
<Grid numItems={1} className="gap-2 p-8 h-[75vh] w-full mt-2">
|
||||
<Col numColSpan={1}>
|
||||
<Title level={4}>All Organizations</Title>
|
||||
{userRole ? OrganizationsTable({organizations, userRole, isDeleteModalOpen, setIsDeleteModalOpen, selectedOrganization, setSelectedOrganization}) : null}
|
||||
</Col>
|
||||
{userRole == "Admin" && accessToken ? <OrganizationForm
|
||||
title="Organization"
|
||||
accessToken={accessToken}
|
||||
availableModels={userModels}
|
||||
submitButtonText="Create Organization"
|
||||
/> : null}
|
||||
<Col numColSpan={1}>
|
||||
<Title level={4}>Organization Members</Title>
|
||||
<Paragraph>
|
||||
If you belong to multiple organizations, this setting controls which organizations'
|
||||
members you see.
|
||||
</Paragraph>
|
||||
{organizations && organizations.length > 0 ? (
|
||||
<Select>
|
||||
{organizations.map((organization: any, index) => (
|
||||
<SelectItem
|
||||
key={index}
|
||||
value={String(index)}
|
||||
onClick={() => {
|
||||
setSelectedOrganization(organization);
|
||||
}}
|
||||
>
|
||||
{organization["organization_alias"]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
) : (
|
||||
<Paragraph>
|
||||
No team created. <b>Defaulting to personal account.</b>
|
||||
</Paragraph>
|
||||
)}
|
||||
</Col>
|
||||
{userRole == "Admin" && userID && selectedOrganization ? <AddOrgAdmin userRole={userRole} userID={userID} selectedOrganization={selectedOrganization} onMemberAdd={handleMemberCreate} /> : null}
|
||||
{userRole == "Admin" && userID && selectedOrganization ? <MemberListTable selectedEntity={selectedOrganization} onEditSubmit={() => {}} editModalComponent={EditOrganizationModal} entityType="organization" /> : null}
|
||||
</Grid>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Organizations;
|
|
@ -655,9 +655,9 @@ const Team: React.FC<TeamProps> = ({
|
|||
{userRole == "Admin"? (
|
||||
<Col numColSpan={1}>
|
||||
<Button
|
||||
className="mx-auto"
|
||||
onClick={() => setIsTeamModalVisible(true)}
|
||||
>
|
||||
className="mx-auto"
|
||||
onClick={() => setIsTeamModalVisible(true)}
|
||||
>
|
||||
+ Create New Team
|
||||
</Button>
|
||||
<Modal
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue