diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.ts new file mode 100644 index 00000000000..3943f23794e --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useCreateProject.ts @@ -0,0 +1,70 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { ProjectResponse, projectKeys } from "./useProjects"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface ProjectCreateParams { + project_alias?: string; + description?: string; + team_id: string; + models?: string[]; + max_budget?: number; + blocked?: boolean; + metadata?: Record; + model_rpm_limit?: Record; + model_tpm_limit?: Record; +} + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const createProject = async ( + accessToken: string, + params: ProjectCreateParams, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl}/project/new`; + + const response = await fetch(url, { + method: "POST", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(params), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return response.json(); +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export const useCreateProject = () => { + const { accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (params) => { + if (!accessToken) { + throw new Error("Access token is required"); + } + return createProject(accessToken, params); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: projectKeys.all }); + }, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.ts new file mode 100644 index 00000000000..85c8b25645c --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useProjects.ts @@ -0,0 +1,87 @@ +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; +import { + getProxyBaseUrl, + getGlobalLitellmHeaderName, + deriveErrorMessage, + handleError, +} from "@/components/networking"; +import { all_admin_roles } from "@/utils/roles"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface ProjectBudget { + budget_id: string; + max_budget: number | null; + soft_budget: number | null; + max_parallel_requests: number | null; + tpm_limit: number | null; + rpm_limit: number | null; + model_max_budget: Record | null; + budget_duration: string | null; +} + +export interface ProjectResponse { + project_id: string; + project_alias: string | null; + description: string | null; + team_id: string | null; + budget_id: string | null; + metadata: Record | null; + models: string[]; + spend: number; + model_spend: Record | null; + model_rpm_limit: Record | null; + model_tpm_limit: Record | null; + blocked: boolean; + object_permission_id: string | null; + created_at: string; + created_by: string; + updated_at: string; + updated_by: string; + litellm_budget_table: ProjectBudget | null; +} + +// ── Query keys (shared across project hooks) ───────────────────────────────── + +export const projectKeys = createQueryKeys("projects"); + +// ── Fetch function ─────────────────────────────────────────────────────────── + +const fetchProjects = async ( + accessToken: string, +): Promise => { + const baseUrl = getProxyBaseUrl(); + const url = `${baseUrl}/project/list`; + + const response = await fetch(url, { + method: "GET", + headers: { + [getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + return response.json(); +}; + +// ── Hook ───────────────────────────────────────────────────────────────────── + +export const useProjects = () => { + const { accessToken, userRole } = useAuthorized(); + + return useQuery({ + queryKey: projectKeys.list({}), + queryFn: async () => fetchProjects(accessToken!), + enabled: + Boolean(accessToken) && all_admin_roles.includes(userRole || ""), + }); +}; diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 258c2ccb0e0..0b2f467e8f8 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -37,6 +37,7 @@ import UIThemeSettings from "@/components/ui_theme_settings"; import Usage from "@/components/usage"; import UserDashboard from "@/components/user_dashboard"; import { AccessGroupsPage } from "@/components/AccessGroups/AccessGroupsPage"; +import { ProjectsPage } from "@/components/Projects/ProjectsPage"; import VectorStoreManagement from "@/components/vector_store_management"; import ToolPolicies from "@/components/ToolPolicies"; import SpendLogsTable from "@/components/view_logs"; @@ -547,6 +548,8 @@ function CreateKeyPageContent() { ) : page == "access-groups" ? ( + ) : page == "projects" ? ( + ) : page == "vector-stores" ? ( ) : page == "tool-policies" ? ( diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx new file mode 100644 index 00000000000..14b4d70b743 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Projects/ProjectModals/CreateProjectModal.tsx @@ -0,0 +1,348 @@ +import { useEffect, useState } from "react"; +import { + Alert, + Modal, + Form, + Input, + Select, + Switch, + InputNumber, + Collapse, + Button, + Col, + Flex, + Row, + Space, + Divider, + Typography, + message, +} from "antd"; +import { FolderAddOutlined, PlusOutlined, MinusCircleOutlined } from "@ant-design/icons"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { useCreateProject, ProjectCreateParams } from "@/app/(dashboard)/hooks/projects/useCreateProject"; +import { Team } from "../../key_team_helpers/key_list"; +import { fetchTeamModels } from "../../organisms/create_key_button"; +import { getModelDisplayName } from "../../key_team_helpers/fetch_available_models_team_key"; + +interface CreateProjectModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function CreateProjectModal({ isOpen, onClose }: CreateProjectModalProps) { + const [form] = Form.useForm(); + const { accessToken, userId, userRole } = useAuthorized(); + const { data: teams } = useTeams(); + const createMutation = useCreateProject(); + + const [selectedTeam, setSelectedTeam] = useState(null); + const [modelsToPick, setModelsToPick] = useState([]); + + // Fetch team-scoped models when team selection changes + useEffect(() => { + if (userId && userRole && accessToken && selectedTeam) { + fetchTeamModels(userId, userRole, accessToken, selectedTeam.team_id).then((models) => { + const allModels = Array.from(new Set([...(selectedTeam.models ?? []), ...models])); + setModelsToPick(allModels); + }); + } else { + setModelsToPick([]); + } + form.setFieldValue("models", []); + }, [selectedTeam, accessToken, userId, userRole, form]); + + const handleSubmit = async () => { + try { + const values = await form.validateFields(); + + // Build model-specific limits from the dynamic form list + const modelRpmLimit: Record = {}; + const modelTpmLimit: Record = {}; + for (const entry of values.modelLimits ?? []) { + if (entry.model) { + if (entry.rpm != null) modelRpmLimit[entry.model] = entry.rpm; + if (entry.tpm != null) modelTpmLimit[entry.model] = entry.tpm; + } + } + + // Build metadata from the dynamic form list + const metadata: Record = {}; + for (const entry of values.metadata ?? []) { + if (entry.key) metadata[entry.key] = entry.value; + } + + const params: ProjectCreateParams = { + project_alias: values.project_alias, + description: values.description, + team_id: values.team_id, + models: values.models ?? [], + max_budget: values.max_budget, + blocked: values.isBlocked ?? false, + ...(Object.keys(modelRpmLimit).length > 0 && { model_rpm_limit: modelRpmLimit }), + ...(Object.keys(modelTpmLimit).length > 0 && { model_tpm_limit: modelTpmLimit }), + ...(Object.keys(metadata).length > 0 && { metadata }), + }; + + createMutation.mutate(params, { + onSuccess: () => { + message.success("Project created successfully"); + form.resetFields(); + setSelectedTeam(null); + setModelsToPick([]); + onClose(); + }, + onError: (error) => { + message.error(error.message || "Failed to create project"); + }, + }); + } catch (error) { + console.error("Validation failed:", error); + } + }; + + const handleCancel = () => { + form.resetFields(); + setSelectedTeam(null); + setModelsToPick([]); + onClose(); + }; + + const handleTeamChange = (teamId: string) => { + const team = teams?.find((t) => t.team_id === teamId) ?? null; + setSelectedTeam(team); + }; + + return ( + + Create New Project + + } + open={isOpen} + onCancel={handleCancel} + width={720} + footer={[ + , + , + ]} + > +
+ {/* Basic Info */} + + Basic Information + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Advanced Settings */} + + + + + Advanced Settings + + } + key="1" + > + + Block Project + + + + + prev.isBlocked !== cur.isBlocked}> + {({ getFieldValue }) => + getFieldValue("isBlocked") ? ( + + ) : null + } + + + + + + Model-Specific Limits + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }) => ( + + + + + + + + + + + remove(name)} style={{ color: "#ef4444" }} /> + + ))} + + + + + )} + + + + + + Metadata + + + {(fields, { add, remove }) => ( + <> + {fields.map(({ key, name, ...restField }) => ( + + + + + + + + remove(name)} style={{ color: "#ef4444" }} /> + + ))} + + + + + )} + + + + + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx b/ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx new file mode 100644 index 00000000000..40ab5045703 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Projects/ProjectsPage.tsx @@ -0,0 +1,211 @@ +import { useProjects, ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; +import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { PlusOutlined } from "@ant-design/icons"; +import { + Button, + Card, + Flex, + Input, + Layout, + Space, + Table, + Tag, + theme, + Tooltip, + Typography, +} from "antd"; +import type { ColumnsType } from "antd/es/table"; +import { LayersIcon, SearchIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; +import { CreateProjectModal } from "./ProjectModals/CreateProjectModal"; + +const { Title, Text } = Typography; +const { Content } = Layout; + +export function ProjectsPage() { + const { token } = theme.useToken(); + const { data: projects, isLoading } = useProjects(); + const { data: teams } = useTeams(); + + const [isCreateModalVisible, setIsCreateModalVisible] = useState(false); + const [searchText, setSearchText] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + useEffect(() => { + setCurrentPage(1); + }, [searchText]); + + // Build a team_id → team_alias lookup from the teams list + const teamAliasMap = useMemo(() => { + const map = new Map(); + for (const team of teams ?? []) { + map.set(team.team_id, team.team_alias ?? team.team_id); + } + return map; + }, [teams]); + + // ---------- filtered data ---------- + const filteredProjects = useMemo(() => { + const list = projects ?? []; + if (!searchText) return list; + const lower = searchText.toLowerCase(); + return list.filter((p) => { + const alias = teamAliasMap.get(p.team_id ?? "") ?? ""; + return ( + (p.project_alias ?? "").toLowerCase().includes(lower) || + p.project_id.toLowerCase().includes(lower) || + (p.description ?? "").toLowerCase().includes(lower) || + alias.toLowerCase().includes(lower) + ); + }); + }, [projects, searchText, teamAliasMap]); + + // ---------- Ant Design columns ---------- + const columns: ColumnsType = [ + { + title: "ID", + dataIndex: "project_id", + key: "project_id", + width: 170, + render: (id: string) => ( + + + {id} + + + ), + }, + { + title: "Name", + dataIndex: "project_alias", + key: "project_alias", + sorter: (a, b) => (a.project_alias ?? "").localeCompare(b.project_alias ?? ""), + render: (alias: string | null) => alias ?? "—", + }, + { + title: "Team", + key: "team", + sorter: (a, b) => { + const aAlias = teamAliasMap.get(a.team_id ?? "") ?? ""; + const bAlias = teamAliasMap.get(b.team_id ?? "") ?? ""; + return aAlias.localeCompare(bAlias); + }, + render: (_: unknown, record: ProjectResponse) => { + const alias = teamAliasMap.get(record.team_id ?? ""); + return alias ?? record.team_id ?? "—"; + }, + }, + { + title: "Models", + key: "models", + render: (_: unknown, record: ProjectResponse) => { + const models = record.models ?? []; + return ( + 0 ? models.join(", ") : "No models"}> + + + + {models.length} + + + + ); + }, + }, + { + title: "Status", + dataIndex: "blocked", + key: "status", + render: (blocked: boolean) => ( + + {blocked ? "Blocked" : "Active"} + + ), + }, + { + title: "Created", + dataIndex: "created_at", + key: "created_at", + sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), + responsive: ["lg"], + render: (date: string) => new Date(date).toLocaleDateString(), + }, + { + title: "Updated", + dataIndex: "updated_at", + key: "updated_at", + responsive: ["xl"], + render: (date: string) => new Date(date).toLocaleDateString(), + }, + ]; + + return ( + + + + + Projects + + + Manage projects within your teams + + + + + + + + } + placeholder="Search projects by name, ID, description, or team..." + style={{ maxWidth: 400 }} + value={searchText} + onChange={(e) => setSearchText(e.target.value)} + allowClear + /> + + setCurrentPage(page), + size: "small", + showTotal: (total) => `${total} projects`, + showSizeChanger: false, + }} + /> + + + setIsCreateModalVisible(false)} + /> + + ); +} diff --git a/ui/litellm-dashboard/src/components/Projects/types.ts b/ui/litellm-dashboard/src/components/Projects/types.ts new file mode 100644 index 00000000000..51429902dff --- /dev/null +++ b/ui/litellm-dashboard/src/components/Projects/types.ts @@ -0,0 +1,14 @@ +export interface Project { + id: string; + name: string; + description: string; + teamId: string; + teamAlias: string; + models: string[]; + status: "active" | "blocked"; + spend: number; + createdAt: string; + createdBy: string; + updatedAt: string; + updatedBy: string; +} diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 2cbeb22ec81..bb0bd54c7a9 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -13,6 +13,7 @@ import { DatabaseOutlined, ExperimentOutlined, FileTextOutlined, + FolderOutlined, KeyOutlined, LineChartOutlined, PlayCircleOutlined, @@ -172,6 +173,23 @@ const menuGroups: MenuGroup[] = [ { groupLabel: "ACCESS CONTROL", items: [ + { + key: "teams", + page: "teams", + label: "Teams", + icon: , + }, + { + key: "projects", + page: "projects", + label: ( + + Projects + + ), + icon: , + roles: all_admin_roles, + }, { key: "users", page: "users", @@ -179,12 +197,6 @@ const menuGroups: MenuGroup[] = [ icon: , roles: all_admin_roles, }, - { - key: "teams", - page: "teams", - label: "Teams", - icon: , - }, { key: "organizations", page: "organizations", @@ -195,11 +207,7 @@ const menuGroups: MenuGroup[] = [ { key: "access-groups", page: "access-groups", - label: ( - - Access Groups - - ), + label: "Access Groups", icon: , roles: all_admin_roles, }, diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts index 5e2c0ccc0b9..a910373d66d 100644 --- a/ui/litellm-dashboard/src/components/page_metadata.ts +++ b/ui/litellm-dashboard/src/components/page_metadata.ts @@ -21,6 +21,7 @@ export const pageDescriptions: Record = { users: "Manage internal user accounts and permissions", teams: "Create and manage teams for access control", organizations: "Manage organizations and their members", + projects: "Manage projects within teams", "access-groups": "Manage access groups for role-based permissions", budgets: "Set and monitor spending budgets", api_ref: "Browse API documentation and endpoints",