Litellm dev 01 24 2025 p4 (#7992)

* feat(team_endpoints.py): new `/teams/available` endpoint - allows proxy admin to expose available teams for users to join on UI

* build(ui/): available_teams.tsx

allow user to join available teams on UI

makes it easier to onboard new users to teams

* fix(navbar.tsx): cleanup title

* fix(team_endpoints.py): fix linting error

* test: update groq model in test

* build(model_prices_and_context_window.json): update groq 3.3 model with 'supports function calling'
This commit is contained in:
Krish Dholakia 2025-01-24 21:29:37 -08:00 committed by GitHub
parent f77882948d
commit 1ab10d8f72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 329 additions and 21 deletions

View file

@ -2064,7 +2064,9 @@
"input_cost_per_token": 0.00000059, "input_cost_per_token": 0.00000059,
"output_cost_per_token": 0.00000079, "output_cost_per_token": 0.00000079,
"litellm_provider": "groq", "litellm_provider": "groq",
"mode": "chat" "mode": "chat",
"supports_function_calling": true,
"supports_response_schema": true
}, },
"groq/llama-3.3-70b-specdec": { "groq/llama-3.3-70b-specdec": {
"max_tokens": 8192, "max_tokens": 8192,

View file

@ -13,3 +13,5 @@ model_list:
litellm_settings: litellm_settings:
callbacks: ["langsmith"] callbacks: ["langsmith"]
default_internal_user_params:
available_teams: ["litellm_dashboard_54a81fa9-9c69-45e8-b256-0c36bf104e5f", "a29a2dc6-1347-4ebc-a428-e6b56bbba611", "test-group-12"]

View file

@ -253,6 +253,7 @@ class LiteLLMRoutes(enum.Enum):
"/key/health", "/key/health",
"/team/info", "/team/info",
"/team/list", "/team/list",
"/team/available",
"/user/info", "/user/info",
"/model/info", "/model/info",
"/v2/model/info", "/v2/model/info",
@ -284,6 +285,7 @@ class LiteLLMRoutes(enum.Enum):
"/team/info", "/team/info",
"/team/block", "/team/block",
"/team/unblock", "/team/unblock",
"/team/available",
# model # model
"/model/new", "/model/new",
"/model/update", "/model/update",
@ -1563,6 +1565,7 @@ class LiteLLM_UserTable(LiteLLMPydanticObjectBase):
rpm_limit: Optional[int] = None rpm_limit: Optional[int] = None
user_role: Optional[str] = None user_role: Optional[str] = None
organization_memberships: Optional[List[LiteLLM_OrganizationMembershipTable]] = None organization_memberships: Optional[List[LiteLLM_OrganizationMembershipTable]] = None
teams: List[str] = []
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
@ -1571,6 +1574,8 @@ class LiteLLM_UserTable(LiteLLMPydanticObjectBase):
values.update({"spend": 0.0}) values.update({"spend": 0.0})
if values.get("models") is None: if values.get("models") is None:
values.update({"models": []}) values.update({"models": []})
if values.get("teams") is None:
values.update({"teams": []})
return values return values
model_config = ConfigDict(protected_namespaces=()) model_config = ConfigDict(protected_namespaces=())

View file

@ -49,7 +49,9 @@ def _update_internal_new_user_params(data_json: dict, data: NewUserRequest) -> d
is_internal_user = True is_internal_user = True
if litellm.default_internal_user_params: if litellm.default_internal_user_params:
for key, value in litellm.default_internal_user_params.items(): for key, value in litellm.default_internal_user_params.items():
if key not in data_json or data_json[key] is None: if key == "available_teams":
continue
elif key not in data_json or data_json[key] is None:
data_json[key] = value data_json[key] = value
elif ( elif (
key == "models" key == "models"

View file

@ -73,6 +73,14 @@ def _is_user_team_admin(
return False return False
def _is_available_team(team_id: str, user_api_key_dict: UserAPIKeyAuth) -> bool:
if litellm.default_internal_user_params is None:
return False
if "available_teams" in litellm.default_internal_user_params:
return team_id in litellm.default_internal_user_params["available_teams"]
return False
async def get_all_team_memberships( async def get_all_team_memberships(
prisma_client: PrismaClient, team_id: List[str], user_id: Optional[str] = None prisma_client: PrismaClient, team_id: List[str], user_id: Optional[str] = None
) -> List[LiteLLM_TeamMembership]: ) -> List[LiteLLM_TeamMembership]:
@ -656,6 +664,10 @@ async def team_member_add(
and not _is_user_team_admin( and not _is_user_team_admin(
user_api_key_dict=user_api_key_dict, team_obj=complete_team_data user_api_key_dict=user_api_key_dict, team_obj=complete_team_data
) )
and not _is_available_team(
team_id=complete_team_data.team_id,
user_api_key_dict=user_api_key_dict,
)
): ):
raise HTTPException( raise HTTPException(
status_code=403, status_code=403,
@ -1363,6 +1375,62 @@ async def unblock_team(
return record return record
@router.get("/team/available")
async def list_available_teams(
http_request: Request,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
response_model=List[LiteLLM_TeamTable],
):
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=400,
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
available_teams = cast(
Optional[List[str]],
(
litellm.default_internal_user_params.get("available_teams")
if litellm.default_internal_user_params is not None
else None
),
)
if available_teams is None:
raise HTTPException(
status_code=400,
detail={
"error": "No available teams for user to join. See how to set available teams here: https://docs.litellm.ai/docs/proxy/self_serve#all-settings-for-self-serve--sso-flow"
},
)
# filter out teams that the user is already a member of
user_info = await prisma_client.db.litellm_usertable.find_unique(
where={"user_id": user_api_key_dict.user_id}
)
if user_info is None:
raise HTTPException(
status_code=404,
detail={"error": "User not found"},
)
user_info_correct_type = LiteLLM_UserTable(**user_info.model_dump())
available_teams = [
team for team in available_teams if team not in user_info_correct_type.teams
]
available_teams_db = await prisma_client.db.litellm_teamtable.find_many(
where={"team_id": {"in": available_teams}}
)
available_teams_correct_type = [
LiteLLM_TeamTable(**team.model_dump()) for team in available_teams_db
]
return available_teams_correct_type
@router.get( @router.get(
"/team/list", tags=["team management"], dependencies=[Depends(user_api_key_auth)] "/team/list", tags=["team management"], dependencies=[Depends(user_api_key_auth)]
) )

View file

@ -2064,7 +2064,9 @@
"input_cost_per_token": 0.00000059, "input_cost_per_token": 0.00000059,
"output_cost_per_token": 0.00000079, "output_cost_per_token": 0.00000079,
"litellm_provider": "groq", "litellm_provider": "groq",
"mode": "chat" "mode": "chat",
"supports_function_calling": true,
"supports_response_schema": true
}, },
"groq/llama-3.3-70b-specdec": { "groq/llama-3.3-70b-specdec": {
"max_tokens": 8192, "max_tokens": 8192,

View file

@ -112,7 +112,7 @@ const Navbar: React.FC<NavbarProps> = ({
</a> </a>
<Dropdown menu={{ items }}> <Dropdown menu={{ items }}>
<button className="flex items-center text-sm text-gray-600 hover:text-gray-800"> <button className="flex items-center text-sm text-gray-600 hover:text-gray-800">
Admin User
<svg <svg
className="ml-1 w-4 h-4" className="ml-1 w-4 h-4"
fill="none" fill="none"

View file

@ -742,6 +742,38 @@ export const teamListCall = async (
} }
}; };
export const availableTeamListCall = async (
accessToken: String,
) => {
/**
* Get all available teams on proxy
*/
try {
let url = proxyBaseUrl ? `${proxyBaseUrl}/team/available` : `/team/available`;
console.log("in availableTeamListCall");
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();
console.log("/team/available_teams API Response:", data);
return data;
} catch (error) {
throw error;
}
};
export const organizationListCall = async (accessToken: String) => { export const organizationListCall = async (accessToken: String) => {
/** /**
* Get all organizations on proxy * Get all organizations on proxy
@ -2278,7 +2310,7 @@ export const modelUpdateCall = async (
export interface Member { export interface Member {
role: string; role: string;
user_id: string | null; user_id: string | null;
user_email: string | null; user_email?: string | null;
} }
export const teamMemberAddCall = async ( export const teamMemberAddCall = async (

View file

@ -0,0 +1,143 @@
import React, { useState, useEffect } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Card,
Button,
Text,
Badge,
} from "@tremor/react";
import { message } from 'antd';
import { availableTeamListCall, teamMemberAddCall } from "../networking";
interface AvailableTeam {
team_id: string;
team_alias: string;
description?: string;
models: string[];
members_with_roles: {user_id?: string, user_email?: string, role: string}[];
}
interface AvailableTeamsProps {
accessToken: string | null;
userID: string | null;
}
const AvailableTeamsPanel: React.FC<AvailableTeamsProps> = ({
accessToken,
userID,
}) => {
const [availableTeams, setAvailableTeams] = useState<AvailableTeam[]>([]);
useEffect(() => {
const fetchAvailableTeams = async () => {
if (!accessToken || !userID) return;
try {
const response = await availableTeamListCall(accessToken);
setAvailableTeams(response);
} catch (error) {
console.error('Error fetching available teams:', error);
message.error('Failed to load available teams');
}
};
fetchAvailableTeams();
}, [accessToken, userID]);
const handleJoinTeam = async (teamId: string) => {
if (!accessToken || !userID) return;
try {
const response = await teamMemberAddCall(accessToken, teamId, {
"user_id": userID,
"role": "user"
}
);
message.success('Successfully joined team');
// Update available teams list
setAvailableTeams(teams => teams.filter(team => team.team_id !== teamId));
} catch (error) {
console.error('Error joining team:', error);
message.error('Failed to join team');
}
};
return (
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]">
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Team Name</TableHeaderCell>
<TableHeaderCell>Description</TableHeaderCell>
<TableHeaderCell>Members</TableHeaderCell>
<TableHeaderCell>Models</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{availableTeams.map((team) => (
<TableRow key={team.team_id}>
<TableCell>
<Text>{team.team_alias}</Text>
</TableCell>
<TableCell>
<Text>{team.description || 'No description available'}</Text>
</TableCell>
<TableCell>
<Text>{team.members_with_roles.length} members</Text>
</TableCell>
<TableCell>
<div className="flex flex-col">
{!team.models || team.models.length === 0 ? (
<Badge size="xs" color="red">
<Text>All Proxy Models</Text>
</Badge>
) : (
team.models.map((model, index) => (
<Badge
key={index}
size="xs"
className="mb-1"
color="blue"
>
<Text>
{model.length > 30 ? `${model.slice(0, 30)}...` : model}
</Text>
</Badge>
))
)}
</div>
</TableCell>
<TableCell>
<Button
size="xs"
variant="secondary"
onClick={() => handleJoinTeam(team.team_id)}
>
Join Team
</Button>
</TableCell>
</TableRow>
))}
{availableTeams.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center">
<Text>No available teams to join</Text>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
);
};
export default AvailableTeamsPanel;

View file

@ -7,6 +7,7 @@ import {
InformationCircleIcon, InformationCircleIcon,
PencilAltIcon, PencilAltIcon,
PencilIcon, PencilIcon,
RefreshIcon,
StatusOnlineIcon, StatusOnlineIcon,
TrashIcon, TrashIcon,
} from "@heroicons/react/outline"; } from "@heroicons/react/outline";
@ -42,8 +43,14 @@ import {
Accordion, Accordion,
AccordionHeader, AccordionHeader,
AccordionBody, AccordionBody,
TabGroup,
TabList,
TabPanel,
TabPanels,
Tab
} from "@tremor/react"; } from "@tremor/react";
import { CogIcon } from "@heroicons/react/outline"; import { CogIcon } from "@heroicons/react/outline";
import AvailableTeamsPanel from "@/components/team/available_teams";
const isLocal = process.env.NODE_ENV === "development"; const isLocal = process.env.NODE_ENV === "development";
const proxyBaseUrl = isLocal ? "http://localhost:4000" : null; const proxyBaseUrl = isLocal ? "http://localhost:4000" : null;
if (isLocal != true) { if (isLocal != true) {
@ -83,12 +90,9 @@ const Team: React.FC<TeamProps> = ({
userID, userID,
userRole, userRole,
}) => { }) => {
const [lastRefreshed, setLastRefreshed] = useState("");
useEffect(() => { const fetchTeams = async (accessToken: string, userID: string | null, userRole: string | null) => {
console.log(`inside useeffect - ${teams}`)
if (teams === null && accessToken) {
// Call your function here
const fetchData = async () => {
let givenTeams; let givenTeams;
if (userRole != "Admin" && userRole != "Admin Viewer") { if (userRole != "Admin" && userRole != "Admin Viewer") {
givenTeams = await teamListCall(accessToken, userID) givenTeams = await teamListCall(accessToken, userID)
@ -100,10 +104,23 @@ const Team: React.FC<TeamProps> = ({
setTeams(givenTeams) setTeams(givenTeams)
} }
fetchData() useEffect(() => {
console.log(`inside useeffect - ${teams}`)
if (teams === null && accessToken) {
// Call your function here
fetchTeams(accessToken, userID, userRole)
} }
}, [teams]); }, [teams]);
useEffect(() => {
console.log(`inside useeffect - ${lastRefreshed}`)
if (accessToken) {
// Call your function here
fetchTeams(accessToken, userID, userRole)
}
handleRefreshClick()
}, [lastRefreshed]);
const [form] = Form.useForm(); const [form] = Form.useForm();
const [memberForm] = Form.useForm(); const [memberForm] = Form.useForm();
const { Title, Paragraph } = Typography; const { Title, Paragraph } = Typography;
@ -123,6 +140,7 @@ const Team: React.FC<TeamProps> = ({
const [selectedEditMember, setSelectedEditMember] = useState<null | TeamMember>(null); const [selectedEditMember, setSelectedEditMember] = useState<null | TeamMember>(null);
const [perTeamInfo, setPerTeamInfo] = useState<Record<string, any>>({}); const [perTeamInfo, setPerTeamInfo] = useState<Record<string, any>>({});
// Add this state near the other useState declarations // Add this state near the other useState declarations
@ -552,6 +570,12 @@ const Team: React.FC<TeamProps> = ({
} }
} }
const handleRefreshClick = () => {
// Update the 'lastRefreshed' state to the current date and time
const currentDate = new Date();
setLastRefreshed(currentDate.toLocaleString());
};
const handleMemberCreate = async (formValues: Record<string, any>) => { const handleMemberCreate = async (formValues: Record<string, any>) => {
_common_member_update_call(formValues, "add"); _common_member_update_call(formValues, "add");
}; };
@ -561,9 +585,27 @@ const Team: React.FC<TeamProps> = ({
} }
return ( return (
<div className="w-full mx-4"> <div className="w-full mx-4">
<Grid numItems={1} className="gap-2 p-8 h-[75vh] w-full mt-2"> <TabGroup className="gap-2 p-8 h-[75vh] w-full mt-2">
<TabList className="flex justify-between mt-2 w-full items-center">
<div className="flex">
<Tab>Your Teams</Tab>
<Tab>Available Teams</Tab>
</div>
<div className="flex items-center space-x-2">
{lastRefreshed && <Text>Last Refreshed: {lastRefreshed}</Text>}
<Icon
icon={RefreshIcon} // Modify as necessary for correct icon name
variant="shadow"
size="xs"
className="self-center"
onClick={handleRefreshClick}
/>
</div>
</TabList>
<TabPanels>
<TabPanel>
<Grid numItems={1} className="gap-2 pt-2 pb-2 h-[75vh] w-full mt-2">
<Col numColSpan={1}> <Col numColSpan={1}>
<Title level={4}>All Teams</Title>
<Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]"> <Card className="w-full mx-auto flex-auto overflow-y-auto max-h-[50vh]">
<Table> <Table>
<TableHead> <TableHead>
@ -1080,6 +1122,16 @@ const Team: React.FC<TeamProps> = ({
</Modal> </Modal>
</Col> </Col>
</Grid> </Grid>
</TabPanel>
<TabPanel>
<AvailableTeamsPanel
accessToken={accessToken}
userID={userID}
/>
</TabPanel>
</TabPanels>
</TabGroup>
</div> </div>
); );
}; };