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 Dashboard404
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") {