Litellm dev 01 10 2025 p2 (#7679)

* test(test_basic_python_version.py): assert all optional dependencies are marked as extras on poetry

Fixes https://github.com/BerriAI/litellm/issues/7677

* docs(secret.md): clarify 'read_and_write' secret manager usage on aws

* docs(secret.md): fix doc

* build(ui/teams.tsx): add edit/delete button for updating user / team membership on ui

allows updating user role to admin on ui

* build(ui/teams.tsx): display edit member component on ui, when edit button on member clicked

* feat(team_endpoints.py): support updating team member role to admin via api endpoints

allows team member to become admin post-add

* build(ui/user_dashboard.tsx): if team admin - show all team keys

Fixes https://github.com/BerriAI/litellm/issues/7650

* test(config.yml): add tomli to ci/cd

* test: don't call python_basic_testing in local testing (covered by python 3.13 testing)
This commit is contained in:
Krish Dholakia 2025-01-10 21:50:53 -08:00 committed by GitHub
parent 49d74748b0
commit c4780479a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 425 additions and 67 deletions

View file

@ -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: |

View file

@ -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 .
```

BIN
dist/litellm-1.57.6.tar.gz vendored Normal file

Binary file not shown.

View file

@ -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"]
```
</TabItem>
<TabItem value="read_and_write" label="Read + Write Keys with AWS Secret Manager">
```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
```
</TabItem>
</Tabs>
@ -186,34 +200,6 @@ LiteLLM stores secret under the `prefix_for_stored_virtual_keys` path (default:
## Azure Key Vault
<!--
### Quick Start
```python
### Instantiate Azure Key Vault Client ###
from azure.keyvault.secrets import SecretClient
from azure.identity import ClientSecretCredential
# Set your Azure Key Vault URI
KVUri = os.getenv("AZURE_KEY_VAULT_URI")
# Set your Azure AD application/client ID, client secret, and tenant ID - create an application with permission to call your key vault
client_id = os.getenv("AZURE_CLIENT_ID")
client_secret = os.getenv("AZURE_CLIENT_SECRET")
tenant_id = os.getenv("AZURE_TENANT_ID")
# Initialize the ClientSecretCredential
credential = ClientSecretCredential(client_id=client_id, client_secret=client_secret, tenant_id=tenant_id)
# Create the SecretClient using the credential
client = SecretClient(vault_url=KVUri, credential=credential)
### Connect to LiteLLM ###
import litellm
litellm.secret_manager = client
litellm.get_secret("your-test-key")
``` -->
#### Usage with LiteLLM Proxy Server

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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,

View file

@ -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<TeamMemberModalProps> = ({
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 (
<Modal
title={mode === 'add' ? "Add Team Member" : "Edit Team Member"}
visible={visible}
width={800}
footer={null}
onCancel={onCancel}
>
<Form
form={form}
onFinish={handleSubmit}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
initialValues={{
user_email: initialData?.email?.trim() || '',
user_id: initialData?.id?.trim() || '',
role: initialData?.role || 'user',
}}
>
<Form.Item
label="Email"
name="user_email"
className="mb-4"
rules={[
{ type: 'email', message: 'Please enter a valid email!' }
]}
>
<Input
name="user_email"
className="px-3 py-2 border rounded-md w-full"
placeholder="user@example.com"
onChange={(e) => {
e.target.value = e.target.value.trim();
}}
/>
</Form.Item>
<div className="text-center mb-4">
<Text>OR</Text>
</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"
placeholder="user_123"
onChange={(e) => {
e.target.value = e.target.value.trim();
}}
/>
</Form.Item>
<Form.Item
label="Member Role"
name="role"
className="mb-4"
rules={[
{ required: true, message: 'Please select a role!' }
]}
>
<AntSelect defaultValue="user">
<AntSelect.Option value="admin">admin</AntSelect.Option>
<AntSelect.Option value="user">user</AntSelect.Option>
</AntSelect>
</Form.Item>
<div style={{ textAlign: "right", marginTop: "20px" }}>
<AntButton
onClick={onCancel}
style={{ marginRight: 8 }}
>
Cancel
</AntButton>
<AntButton
type="primary"
htmlType="submit"
>
{mode === 'add' ? 'Add Member' : 'Save Changes'}
</AntButton>
</div>
</Form>
</Modal>
);
};
export default TeamMemberModal;

View file

@ -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<TeamProps> = ({
teams,
searchParams,
@ -112,11 +113,12 @@ const Team: React.FC<TeamProps> = ({
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<string | null>(null);
const [selectedEditMember, setSelectedEditMember] = useState<null | TeamMember>(null);
// store team info as {"team_id": team_info_object}
const [perTeamInfo, setPerTeamInfo] = useState<Record<string, any>>({});
const EditTeamModal: React.FC<EditTeamModalProps> = ({
@ -257,16 +259,19 @@ const Team: React.FC<TeamProps> = ({
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<TeamProps> = ({
return false;
}
const handleMemberCreate = async (formValues: Record<string, any>) => {
const _common_member_update_call = async (formValues: Record<string, any>, callType: "add" | "edit") => {
try {
if (accessToken != null && teams != null) {
message.info("Adding Member");
@ -421,13 +426,27 @@ const Team: React.FC<TeamProps> = ({
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<TeamProps> = ({
} catch (error) {
console.error("Error creating the team:", error);
}
}
const handleMemberCreate = async (formValues: Record<string, any>) => {
_common_member_update_call(formValues, "add");
};
const handleMemberUpdate = async (formValues: Record<string, any>) => {
_common_member_update_call(formValues, "edit");
}
return (
<div className="w-full mx-4">
<Grid numItems={1} className="gap-2 p-8 h-[75vh] w-full mt-2">
@ -831,6 +858,30 @@ const Team: React.FC<TeamProps> = ({
: null}
</TableCell>
<TableCell>{member["role"]}</TableCell>
<TableCell>
{userRole == "Admin" ? (
<>
<Icon
icon={PencilAltIcon}
size="sm"
onClick={() => {
setIsEditMemberModalVisible(true);
setSelectedEditMember({
"id": member["user_id"],
"email": member["user_email"],
"team_id": selectedTeam["team_id"],
"role": member["role"]
})
}}
/>
<Icon
onClick={() => {}}
icon={TrashIcon}
size="sm"
/>
</>
) : null}
</TableCell>
</TableRow>
)
)
@ -838,6 +889,13 @@ const Team: React.FC<TeamProps> = ({
</TableBody>
</Table>
</Card>
<TeamMemberModal
visible={isEditMemberModalVisible}
onCancel={handleMemberCancel}
onSubmit={handleMemberUpdate}
initialData={selectedEditMember}
mode="edit"
/>
{selectedTeam && (
<EditTeamModal
visible={editModalVisible}

View file

@ -5,6 +5,7 @@ import {
modelAvailableCall,
getTotalSpendCall,
getProxyUISettings,
teamListCall,
} from "./networking";
import { Grid, Col, Card, Text, Title } from "@tremor/react";
import CreateKey from "./create_key_button";
@ -172,6 +173,18 @@ const UserDashboard: React.FC<UserDashboardProps> = ({
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<UserDashboardProps> = ({
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<UserDashboardProps> = ({
}
};
fetchData();
fetchTeams();
}
}
}, [userID, token, accessToken, keys, userRole]);

View file

@ -131,6 +131,57 @@ const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
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<ViewKeyTableProps> = ({
</TableRow>
</TableHead>
<TableBody>
{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") {