diff --git a/.circleci/config.yml b/.circleci/config.yml index 5bbd09254216..2072578709d3 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -105,7 +105,7 @@ jobs: command: | pwd ls - python -m pytest -vv tests/local_testing --cov=litellm --cov-report=xml -x --junitxml=test-results/junit.xml --durations=5 -k "not test_python_38.py and not router and not assistants and not langfuse and not caching and not cache" -n 4 + python -m pytest -vv tests/local_testing --cov=litellm --cov-report=xml -x --junitxml=test-results/junit.xml --durations=5 -k "not test_python_38.py and not test_basic_python_version.py and not router and not assistants and not langfuse and not caching and not cache" -n 4 no_output_timeout: 120m - run: name: Rename the coverage files @@ -895,6 +895,7 @@ jobs: pip install "pytest-retry==1.6.3" pip install "pytest-asyncio==0.21.1" pip install "pytest-cov==5.0.0" + pip install "tomli==2.2.1" - run: name: Run tests command: | diff --git a/README.md b/README.md index c0a5d4265924..0474b69c0cfb 100644 --- a/README.md +++ b/README.md @@ -358,7 +358,7 @@ poetry install -E extra_proxy -E proxy Step 3: Test your change: ``` -cd litellm/tests # pwd: Documents/litellm/litellm/tests +cd tests # pwd: Documents/litellm/litellm/tests poetry run flake8 poetry run pytest . ``` diff --git a/dist/litellm-1.57.6.tar.gz b/dist/litellm-1.57.6.tar.gz new file mode 100644 index 000000000000..01a039cf6eea Binary files /dev/null and b/dist/litellm-1.57.6.tar.gz differ diff --git a/docs/my-website/docs/secret.md b/docs/my-website/docs/secret.md index 22bad6fec18b..a65c696f367b 100644 --- a/docs/my-website/docs/secret.md +++ b/docs/my-website/docs/secret.md @@ -72,6 +72,20 @@ general_settings: prefix_for_stored_virtual_keys: "litellm/" # OPTIONAL. If set, this prefix will be used for stored virtual keys in the secret manager access_mode: "write_only" # Literal["read_only", "write_only", "read_and_write"] ``` + + + +```yaml +general_settings: + master_key: os.environ/litellm_master_key + key_management_system: "aws_secret_manager" # 👈 KEY CHANGE + key_management_settings: + store_virtual_keys: true # OPTIONAL. Defaults to False, when True will store virtual keys in secret manager + prefix_for_stored_virtual_keys: "litellm/" # OPTIONAL. If set, this prefix will be used for stored virtual keys in the secret manager + access_mode: "read_and_write" # Literal["read_only", "write_only", "read_and_write"] + hosted_keys: ["litellm_master_key"] # OPTIONAL. Specify which env keys you stored on AWS +``` + @@ -186,34 +200,6 @@ LiteLLM stores secret under the `prefix_for_stored_virtual_keys` path (default: ## Azure Key Vault - #### Usage with LiteLLM Proxy Server diff --git a/litellm/proxy/_experimental/out/404.html b/litellm/proxy/_experimental/out/404.html deleted file mode 100644 index 3bbcc888404d..000000000000 --- 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 8a63b4419fad..000000000000 --- 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 070f19d8e3f5..000000000000 --- 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/_types.py b/litellm/proxy/_types.py index 6f3261fbd7af..b188e981f7e3 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2078,12 +2078,13 @@ class TeamMemberDeleteRequest(MemberDeleteRequest): class TeamMemberUpdateRequest(TeamMemberDeleteRequest): - max_budget_in_team: float + max_budget_in_team: Optional[float] = None + role: Optional[Literal["admin", "user"]] = None class TeamMemberUpdateResponse(MemberUpdateResponse): team_id: str - max_budget_in_team: float + max_budget_in_team: Optional[float] = None # Organization Member Requests diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index cab4850f2a32..e63a472b6131 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -920,7 +920,7 @@ async def team_member_update( """ [BETA] - Update team member budgets + Update team member budgets and team member role """ from litellm.proxy.proxy_server import prisma_client @@ -970,6 +970,8 @@ async def team_member_update( user_api_key_dict=user_api_key_dict, ) + team_table = returned_team_info["team_info"] + ## get user id received_user_id: Optional[str] = None if data.user_id is not None: @@ -995,26 +997,50 @@ async def team_member_update( break ### upsert new budget - if identified_budget_id is None: - new_budget = await prisma_client.db.litellm_budgettable.create( - data={ - "max_budget": data.max_budget_in_team, - "created_by": user_api_key_dict.user_id or "", - "updated_by": user_api_key_dict.user_id or "", - } - ) + if data.max_budget_in_team is not None: + if identified_budget_id is None: + new_budget = await prisma_client.db.litellm_budgettable.create( + data={ + "max_budget": data.max_budget_in_team, + "created_by": user_api_key_dict.user_id or "", + "updated_by": user_api_key_dict.user_id or "", + } + ) - await prisma_client.db.litellm_teammembership.create( - data={ - "team_id": data.team_id, - "user_id": received_user_id, - "budget_id": new_budget.budget_id, - }, - ) - else: - await prisma_client.db.litellm_budgettable.update( - where={"budget_id": identified_budget_id}, - data={"max_budget": data.max_budget_in_team}, + await prisma_client.db.litellm_teammembership.create( + data={ + "team_id": data.team_id, + "user_id": received_user_id, + "budget_id": new_budget.budget_id, + }, + ) + elif identified_budget_id is not None: + await prisma_client.db.litellm_budgettable.update( + where={"budget_id": identified_budget_id}, + data={"max_budget": data.max_budget_in_team}, + ) + + ### update team member role + if data.role is not None: + team_members: List[Member] = [] + for member in team_table.members_with_roles: + if member.user_id == received_user_id: + team_members.append( + Member( + user_id=member.user_id, + role=data.role, + user_email=data.user_email or member.user_email, + ) + ) + else: + team_members.append(member) + + team_table.members_with_roles = team_members + + _db_team_members: List[dict] = [m.model_dump() for m in team_members] + await prisma_client.db.litellm_teamtable.update( + where={"team_id": data.team_id}, + data={"members_with_roles": json.dumps(_db_team_members)}, # type: ignore ) return TeamMemberUpdateResponse( diff --git a/tests/local_testing/test_basic_python_version.py b/tests/local_testing/test_basic_python_version.py index 5fa48f09699d..c629ef3df8e9 100644 --- a/tests/local_testing/test_basic_python_version.py +++ b/tests/local_testing/test_basic_python_version.py @@ -37,6 +37,51 @@ def test_litellm_proxy_server(): assert True +def test_package_dependencies(): + try: + import tomli + import pathlib + import litellm + + # Get the litellm package root path + litellm_path = pathlib.Path(litellm.__file__).parent.parent + pyproject_path = litellm_path / "pyproject.toml" + + # Read and parse pyproject.toml + with open(pyproject_path, "rb") as f: + pyproject = tomli.load(f) + + # Get all optional dependencies from poetry.dependencies + poetry_deps = pyproject["tool"]["poetry"]["dependencies"] + optional_deps = { + name.lower() + for name, value in poetry_deps.items() + if isinstance(value, dict) and value.get("optional", False) + } + print(optional_deps) + # Get all packages listed in extras + extras = pyproject["tool"]["poetry"]["extras"] + all_extra_deps = set() + for extra_group in extras.values(): + all_extra_deps.update(dep.lower() for dep in extra_group) + print(all_extra_deps) + # Check that all optional dependencies are in some extras group + missing_from_extras = optional_deps - all_extra_deps + assert ( + not missing_from_extras + ), f"Optional dependencies missing from extras: {missing_from_extras}" + + print( + f"All {len(optional_deps)} optional dependencies are correctly specified in extras" + ) + + except Exception as e: + pytest.fail( + f"Error occurred while checking dependencies: {str(e)}\n" + + traceback.format_exc() + ) + + import os import subprocess import time diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 954f90c9b08e..744442ec7c31 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2267,6 +2267,47 @@ export const teamMemberAddCall = async ( } }; +export const teamMemberUpdateCall = async ( + accessToken: string, + teamId: 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}/team/member_update` + : `/team/member_update`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + team_id: teamId, + role: formValues.role, + user_id: formValues.user_id, + }), + }); + + 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 organizationMemberAddCall = async ( accessToken: string, organizationId: string, diff --git a/ui/litellm-dashboard/src/components/team/edit_membership.tsx b/ui/litellm-dashboard/src/components/team/edit_membership.tsx new file mode 100644 index 000000000000..31339d420a21 --- /dev/null +++ b/ui/litellm-dashboard/src/components/team/edit_membership.tsx @@ -0,0 +1,139 @@ +import React, { useState } from 'react'; +import { Modal, Form, Input, Select as AntSelect, Button as AntButton, message } from 'antd'; +import { Select, SelectItem } from "@tremor/react"; +import { Card, Text } from "@tremor/react"; + +export interface TeamMember { + id?: string; + email?: string; + role: 'admin' | 'user'; + team_id: string; +} + +interface TeamMemberModalProps { + visible: boolean; + onCancel: () => void; + onSubmit: (data: TeamMember) => void; + initialData?: TeamMember | null; + mode: 'add' | 'edit'; +} + +const TeamMemberModal: React.FC = ({ + visible, + onCancel, + onSubmit, + initialData, + mode +}) => { + const [form] = Form.useForm(); + + const handleSubmit = async (values: any) => { + try { + const formData: TeamMember = { + email: values.user_email, + id: values.user_id, + role: values.role + }; + + onSubmit(formData); + form.resetFields(); + message.success(`Successfully ${mode === 'add' ? 'added' : 'updated'} team member`); + } catch (error) { + message.error('Failed to submit form'); + console.error('Form submission error:', error); + } + }; + + + + return ( + +
+ + { + e.target.value = e.target.value.trim(); + }} + /> + + +
+ OR +
+ + + { + e.target.value = e.target.value.trim(); + }} + /> + + + + + admin + user + + + +
+ + Cancel + + + {mode === 'add' ? 'Add Member' : 'Save Changes'} + +
+
+
+ ); +}; + +export default TeamMemberModal; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/teams.tsx b/ui/litellm-dashboard/src/components/teams.tsx index fc43ececc3cf..30c4414c9764 100644 --- a/ui/litellm-dashboard/src/components/teams.tsx +++ b/ui/litellm-dashboard/src/components/teams.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from "react"; import Link from "next/link"; import { Typography } from "antd"; import { teamDeleteCall, teamUpdateCall, teamInfoCall } from "./networking"; +import TeamMemberModal, { TeamMember } from "@/components/team/edit_membership"; import { InformationCircleIcon, PencilAltIcon, @@ -65,12 +66,12 @@ interface EditTeamModalProps { import { teamCreateCall, teamMemberAddCall, + teamMemberUpdateCall, Member, modelAvailableCall, teamListCall } from "./networking"; - const Team: React.FC = ({ teams, searchParams, @@ -112,11 +113,12 @@ const Team: React.FC = ({ const [isTeamModalVisible, setIsTeamModalVisible] = useState(false); const [isAddMemberModalVisible, setIsAddMemberModalVisible] = useState(false); + const [isEditMemberModalVisible, setIsEditMemberModalVisible] = useState(false); const [userModels, setUserModels] = useState([]); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [teamToDelete, setTeamToDelete] = useState(null); + const [selectedEditMember, setSelectedEditMember] = useState(null); - // store team info as {"team_id": team_info_object} const [perTeamInfo, setPerTeamInfo] = useState>({}); const EditTeamModal: React.FC = ({ @@ -257,16 +259,19 @@ const Team: React.FC = ({ const handleMemberOk = () => { setIsAddMemberModalVisible(false); + setIsEditMemberModalVisible(false); memberForm.resetFields(); }; const handleCancel = () => { setIsTeamModalVisible(false); + form.resetFields(); }; const handleMemberCancel = () => { setIsAddMemberModalVisible(false); + setIsEditMemberModalVisible(false); memberForm.resetFields(); }; @@ -412,7 +417,7 @@ const Team: React.FC = ({ return false; } - const handleMemberCreate = async (formValues: Record) => { + const _common_member_update_call = async (formValues: Record, callType: "add" | "edit") => { try { if (accessToken != null && teams != null) { message.info("Adding Member"); @@ -421,13 +426,27 @@ const Team: React.FC = ({ user_email: formValues.user_email, user_id: formValues.user_id, }; - const response: any = await teamMemberAddCall( - accessToken, - selectedTeam["team_id"], - user_role - ); - message.success("Member added"); - console.log(`response for team create call: ${response["data"]}`); + let response: any; + if (callType == "add") { + response = await teamMemberAddCall( + accessToken, + selectedTeam["team_id"], + user_role + ); + message.success("Member added"); + } else { + response = await teamMemberUpdateCall( + accessToken, + selectedTeam["team_id"], + { + "role": formValues.role, + "user_id": formValues.id, + "user_email": formValues.email + } + ); + message.success("Member updated"); + } + // Checking if the team exists in the list and updating or adding accordingly const foundIndex = teams.findIndex((team) => { console.log( @@ -449,7 +468,15 @@ const Team: React.FC = ({ } catch (error) { console.error("Error creating the team:", error); } + } + + const handleMemberCreate = async (formValues: Record) => { + _common_member_update_call(formValues, "add"); }; + + const handleMemberUpdate = async (formValues: Record) => { + _common_member_update_call(formValues, "edit"); + } return (
@@ -831,6 +858,30 @@ const Team: React.FC = ({ : null} {member["role"]} + + {userRole == "Admin" ? ( + <> + { + setIsEditMemberModalVisible(true); + setSelectedEditMember({ + "id": member["user_id"], + "email": member["user_email"], + "team_id": selectedTeam["team_id"], + "role": member["role"] + }) + }} + /> + {}} + icon={TrashIcon} + size="sm" + /> + + ) : null} + ) ) @@ -838,6 +889,13 @@ const Team: React.FC = ({ + {selectedTeam && ( = ({ if (cachedUserModels) { setUserModels(JSON.parse(cachedUserModels)); } else { + const fetchTeams = async () => { + let givenTeams; + if (userRole != "Admin" && userRole != "Admin Viewer") { + givenTeams = await teamListCall(accessToken, userID) + } else { + givenTeams = await teamListCall(accessToken) + } + + console.log(`givenTeams: ${givenTeams}`) + + setTeams(givenTeams) + } const fetchData = async () => { try { const proxy_settings: ProxySettings = await getProxyUISettings(accessToken); @@ -194,7 +207,6 @@ const UserDashboard: React.FC = ({ setUserSpendData(response["user_info"]); console.log(`userSpendData: ${JSON.stringify(userSpendData)}`) setKeys(response["keys"]); // Assuming this is the correct path to your data - setTeams(response["teams"]); const teamsArray = [...response["teams"]]; if (teamsArray.length > 0) { console.log(`response['teams']: ${teamsArray}`); @@ -235,6 +247,7 @@ const UserDashboard: React.FC = ({ } }; fetchData(); + fetchTeams(); } } }, [userID, token, accessToken, keys, userRole]); diff --git a/ui/litellm-dashboard/src/components/view_key_table.tsx b/ui/litellm-dashboard/src/components/view_key_table.tsx index 1bdc31500f16..55a74888a78a 100644 --- a/ui/litellm-dashboard/src/components/view_key_table.tsx +++ b/ui/litellm-dashboard/src/components/view_key_table.tsx @@ -131,6 +131,57 @@ const ViewKeyTable: React.FC = ({ const [knownTeamIDs, setKnownTeamIDs] = useState(initialKnownTeamIDs); + // Function to check if user is admin of a team + const isUserTeamAdmin = (team: any) => { + if (!team.members_with_roles) return false; + return team.members_with_roles.some( + (member: any) => member.role === "admin" && member.user_id === userID + ); + }; + + // Combine all keys that user should have access to + const all_keys_to_display = React.useMemo(() => { + let allKeys: any[] = []; + + // If no teams, return personal keys + if (!teams || teams.length === 0) { + return data; + } + + teams.forEach(team => { + // For default team or when user is not admin, use personal keys (data) + if (team.team_id === "default-team" || !isUserTeamAdmin(team)) { + if (selectedTeam && selectedTeam.team_id === team.team_id) { + allKeys = [...allKeys, ...data.filter(key => key.team_id === team.team_id)]; + } + } + // For teams where user is admin, use team keys + else if (isUserTeamAdmin(team)) { + if (selectedTeam && selectedTeam.team_id === team.team_id) { + allKeys = [...allKeys, ...(team.keys || [])]; + } + } + }); + + // If no team is selected, show all accessible keys + if (!selectedTeam) { + const personalKeys = data.filter(key => !key.team_id || key.team_id === "default-team"); + const adminTeamKeys = teams + .filter(team => isUserTeamAdmin(team)) + .flatMap(team => team.keys || []); + allKeys = [...personalKeys, ...adminTeamKeys]; + } + + // Filter out litellm-dashboard keys + allKeys = allKeys.filter(key => key.team_id !== "litellm-dashboard"); + + // Remove duplicates based on token + const uniqueKeys = Array.from( + new Map(allKeys.map(key => [key.token, key])).values() + ); + + return uniqueKeys; + }, [data, teams, selectedTeam, userID]); useEffect(() => { const calculateNewExpiryTime = (duration: string | undefined) => { @@ -858,7 +909,7 @@ const ViewKeyTable: React.FC = ({ - {data.map((item) => { + {all_keys_to_display.map((item) => { console.log(item); // skip item if item.team_id == "litellm-dashboard" if (item.team_id === "litellm-dashboard") {